chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,163 @@
// @region[backend-render-operations]
// @region[backend-schema-json-load]
using System.ClientModel;
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using OpenAI;
/// <summary>
/// Factory for the A2UI — Fixed Schema agent.
///
/// Mirrors the LangGraph `src/agents/a2ui_fixed.py` reference: the frontend
/// owns a pre-authored component tree (see
/// `src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts` + flight_schema.json)
/// and the agent only streams *data* into the data model via a dedicated
/// `display_flight` tool that emits an <c>a2ui_operations</c> container.
/// The A2UI middleware detects that container in the tool result and
/// forwards rendered surfaces to the frontend.
/// </summary>
public class A2uiFixedSchemaAgent
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string CatalogId = "copilotkit://flight-fixed-catalog";
private const string SurfaceId = "flight-fixed-schema";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonSerializerOptions;
public A2uiFixedSchemaAgent(IConfiguration configuration, ILoggerFactory loggerFactory, JsonSerializerOptions jsonSerializerOptions)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
_logger = loggerFactory.CreateLogger<A2uiFixedSchemaAgent>();
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent Create()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "A2uiFixedSchemaAgent",
description: @"You help users find flights. When asked about a flight, call
`display_flight` with origin, destination, airline, and price.
Use short airport codes (e.g. ""SFO"", ""JFK"") for origin/destination and a price
string like ""$289"". Keep any chat reply to one short sentence.",
tools: [
AIFunctionFactory.Create(DisplayFlight, options: new() { Name = "display_flight", SerializerOptions = _jsonSerializerOptions })
]);
}
// The fixed-schema flight component tree. .NET doesn't ship a
// JSON-loading helper analogous to LangGraph Python's
// `a2ui.load_schema(...)`, so the schema is declared inline as a
// C# array — equivalent to deserialising a `flight_schema.json`
// file at startup. Matches the LangGraph reference at
// `src/agents/a2ui_schemas/flight_schema.json`. Frontend renders
// this via the registered catalog (`copilotkit://flight-fixed-catalog`).
private static readonly object[] FlightSchema = new object[]
{
new { id = "root", component = "Card", child = "content" },
new { id = "content", component = "Column", children = new[] { "title", "route", "meta", "bookButton" } },
new { id = "title", component = "Title", text = "Flight Details" },
new
{
id = "route",
component = "Row",
justify = "spaceBetween",
align = "center",
children = new[] { "from", "arrow", "to" },
},
new { id = "from", component = "Airport", code = new { path = "/origin" } },
new { id = "arrow", component = "Arrow" },
new { id = "to", component = "Airport", code = new { path = "/destination" } },
new
{
id = "meta",
component = "Row",
justify = "spaceBetween",
align = "center",
children = new[] { "airline", "price" },
},
new { id = "airline", component = "AirlineBadge", name = new { path = "/airline" } },
new { id = "price", component = "PriceTag", amount = new { path = "/price" } },
new
{
id = "bookButton",
component = "Button",
variant = "primary",
child = "bookButtonLabel",
action = new
{
@event = new
{
name = "book_flight",
context = new
{
origin = new { path = "/origin" },
destination = new { path = "/destination" },
airline = new { path = "/airline" },
price = new { path = "/price" },
},
},
},
},
new { id = "bookButtonLabel", component = "Text", text = "Book flight" },
};
// @endregion[backend-schema-json-load]
[Description("Show a flight card for the given trip. Use short airport codes (e.g. SFO, JFK) for origin/destination and a price string like $289.")]
private object DisplayFlight(
[Description("Origin airport code (e.g. SFO)")] string origin,
[Description("Destination airport code (e.g. JFK)")] string destination,
[Description("Airline name")] string airline,
[Description("Price string (e.g. $289)")] string price)
{
_logger.LogInformation("FixedSchema DisplayFlight: {Origin} -> {Destination} on {Airline} at {Price}", origin, destination, airline, price);
var operations = new object[]
{
new { version = "v0.9", createSurface = new { surfaceId = SurfaceId, catalogId = CatalogId } },
new { version = "v0.9", updateComponents = new { surfaceId = SurfaceId, components = FlightSchema } },
new
{
version = "v0.9",
updateDataModel = new
{
surfaceId = SurfaceId,
path = "/",
value = new
{
origin,
destination,
airline,
price,
},
},
},
};
return new { a2ui_operations = operations };
}
// @endregion[backend-render-operations]
}
@@ -0,0 +1,230 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
internal static class A2uiSecondaryToolCaller
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string DesignToolName = "_design_a2ui_surface";
internal static async Task<string?> GetDesignToolArgumentsAsync(
IConfiguration configuration,
string systemPrompt,
string userContent,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(systemPrompt);
ArgumentNullException.ThrowIfNull(userContent);
var endpoint = (Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint).TrimEnd('/');
// Fail loud if no credential resolves — matches the primary
// SalesAgentFactory, which throws when GitHubToken is absent. Previously
// this fell back to a bogus "sk-mock-local" key, which got sent verbatim
// to whatever real endpoint OPENAI_BASE_URL pointed at, producing a
// confusing 401 from upstream instead of a clear local configuration
// error. aimock-backed test runs still resolve a real key via the
// GitHubToken fallback (aimock is selected by OPENAI_BASE_URL + the
// forwarded x-aimock-context header, not by a sentinel key), so no
// mock-mode gate is needed here.
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? configuration["OPENAI_API_KEY"]
?? configuration["GitHubToken"]
?? throw new InvalidOperationException(
"No OpenAI credential found for the A2UI secondary tool caller. " +
"Set the OPENAI_API_KEY environment variable, or provide OPENAI_API_KEY / " +
"GitHubToken in configuration (e.g. dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token).");
using var httpClient = new HttpClient
{
BaseAddress = new Uri(endpoint + "/"),
};
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
// Forward the inbound x-* headers (incl. x-aimock-context) read off the
// current request's HttpContext.Items via the seeded accessor. This
// secondary outbound call runs on the SSE-pump ExecutionContext, so it
// must read through the accessor (not a middleware-set AsyncLocal) for
// the value to be present at call time.
if (AimockHeaderPolicy.HttpContextAccessor?.HttpContext is null)
{
CvDiag.Logger?.LogWarning("A2uiSecondaryToolCaller: no HttpContext resolved (HttpContextAccessor null or no request in scope); forwarding empty x-* header set — x-aimock-context will be absent on the secondary call.");
}
foreach (var header in AimockHeaderContext.Get(AimockHeaderPolicy.HttpContextAccessor?.HttpContext))
{
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
var payload = new
{
model = "gpt-4.1",
messages = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userContent },
},
tools = new object[]
{
new
{
type = "function",
function = new
{
name = DesignToolName,
description = "Render a dynamic A2UI v0.9 surface.",
parameters = new
{
type = "object",
properties = new Dictionary<string, object>
{
["surfaceId"] = new { type = "string" },
["catalogId"] = new { type = "string" },
["components"] = new { type = "array", items = new { type = "object" } },
["data"] = new { type = "object" },
},
required = new[] { "surfaceId", "catalogId", "components" },
},
},
},
},
tool_choice = new
{
type = "function",
function = new { name = DesignToolName },
},
};
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// Don't use EnsureSuccessStatusCode(): the HttpRequestException it
// throws carries the status code but discards the response body we
// already read. Throw our own with a truncated body so the upstream
// detail (e.g. the provider's error message for a 401/429) is captured
// for server-side logging, and so the StatusCode survives for the
// caller to classify retryable vs non-retryable failures.
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"A2UI secondary tool caller upstream returned {(int)response.StatusCode} " +
$"({response.StatusCode}). Body: {Truncate(body, 2048)}",
inner: null,
statusCode: response.StatusCode);
}
// A successful HTTP status but a structurally-malformed body must not
// surface as an uncaught KeyNotFoundException from a bare GetProperty.
// Parse defensively, capture the body for logging, and raise a typed
// signal the caller maps to a specific structured error.
return ParseDesignToolArguments(body);
}
/// <summary>
/// Parses the chat-completions response body and extracts the
/// <c>_design_a2ui_surface</c> tool-call arguments. Returns <c>null</c> for
/// the expected "no usable tool call" cases (missing/empty choices, missing
/// tool_calls, wrong tool name, missing arguments). Throws
/// <see cref="A2uiUpstreamResponseException"/> for a structurally-malformed
/// response (e.g. a choice with no <c>message</c>, or a tool call with no
/// <c>function</c>) so the caller can return a specific structured error
/// with the upstream body captured for logging — rather than letting an
/// uncaught <see cref="KeyNotFoundException"/> escape.
/// </summary>
internal static string? ParseDesignToolArguments(string body)
{
ArgumentNullException.ThrowIfNull(body);
JsonDocument document;
try
{
document = JsonDocument.Parse(body);
}
catch (JsonException ex)
{
throw new A2uiUpstreamResponseException(
"Upstream returned a 2xx status but a non-JSON body.", body, ex);
}
using (document)
{
if (!document.RootElement.TryGetProperty("choices", out var choices) ||
choices.ValueKind != JsonValueKind.Array ||
choices.GetArrayLength() == 0)
{
return null;
}
// choices[0] exists (length checked above), but "message" may be
// absent on a malformed response — guard the deref.
if (!choices[0].TryGetProperty("message", out var message))
{
throw new A2uiUpstreamResponseException(
"Upstream choice had no 'message' property.", body);
}
if (!message.TryGetProperty("tool_calls", out var toolCalls) ||
toolCalls.ValueKind != JsonValueKind.Array ||
toolCalls.GetArrayLength() == 0)
{
return null;
}
// toolCalls[0] exists (length checked above), but "function" may be
// absent on a malformed response — guard the deref.
if (!toolCalls[0].TryGetProperty("function", out var function))
{
throw new A2uiUpstreamResponseException(
"Upstream tool call had no 'function' property.", body);
}
var toolName = function.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null;
if (!string.Equals(toolName, DesignToolName, StringComparison.Ordinal))
{
return null;
}
if (!function.TryGetProperty("arguments", out var argumentsElement))
{
return null;
}
return argumentsElement.ValueKind == JsonValueKind.String
? argumentsElement.GetString()
: argumentsElement.GetRawText();
}
}
private static string Truncate(string value, int max) =>
value.Length <= max ? value : value[..max] + "…(truncated)";
}
/// <summary>
/// Raised when the A2UI secondary tool caller's upstream returns a 2xx status
/// but a body that is non-JSON or structurally missing the expected
/// <c>choices[].message</c> / <c>tool_calls[].function</c> shape. Carries the
/// (truncated) response <see cref="Body"/> so the caller can log the upstream
/// detail with a correlation id before returning a structured error.
/// </summary>
internal sealed class A2uiUpstreamResponseException : Exception
{
public string Body { get; }
public A2uiUpstreamResponseException(string message, string body)
: base(message) => Body = body;
public A2uiUpstreamResponseException(string message, string body, Exception inner)
: base(message, inner) => Body = body;
}
@@ -0,0 +1,150 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
// AgentConfigAgent — the /agent-config demo.
//
// Reads three forwarded properties — tone, expertise, responseLength — from
// the AG-UI shared-state payload (attached as `ag_ui_state` on
// ChatClientAgentRunOptions.AdditionalProperties, matching the convention
// already used by SharedStateAgent) and builds a dynamic system prompt per
// turn.
//
// The frontend <CopilotKitProvider agent="agent-config-demo" />'s
// useAgent().setState(...) call pushes the typed config into shared state;
// this agent reads it on every run and prepends a system message that adapts
// the inner ChatClientAgent's behavior. Missing / unrecognized values fall
// back to the documented defaults — the agent never throws on malformed
// config, so a misbehaving frontend can't kill the demo.
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by SalesAgentFactory")]
internal sealed class AgentConfigAgent : DelegatingAIAgent
{
private static readonly HashSet<string> ValidTones = new(StringComparer.Ordinal)
{
"professional",
"casual",
"enthusiastic",
};
private static readonly HashSet<string> ValidExpertise = new(StringComparer.Ordinal)
{
"beginner",
"intermediate",
"expert",
};
private static readonly HashSet<string> ValidResponseLengths = new(StringComparer.Ordinal)
{
"concise",
"detailed",
};
private const string DefaultTone = "professional";
private const string DefaultExpertise = "intermediate";
private const string DefaultResponseLength = "concise";
private readonly ILogger<AgentConfigAgent> _logger;
public AgentConfigAgent(AIAgent innerAgent, ILogger<AgentConfigAgent>? logger = null)
: base(innerAgent)
{
ArgumentNullException.ThrowIfNull(innerAgent);
_logger = logger ?? NullLogger<AgentConfigAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(messages);
// Materialize up-front so we can both inspect it (to read the state)
// and forward it to the inner agent without re-enumerating a
// single-use iterator.
var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
var (tone, expertise, responseLength) = ReadConfig(options);
var systemPrompt = BuildSystemPrompt(tone, expertise, responseLength);
_logger.LogInformation(
"AgentConfigAgent: tone={Tone}, expertise={Expertise}, responseLength={ResponseLength}",
tone, expertise, responseLength);
var systemMessage = new ChatMessage(ChatRole.System, systemPrompt);
var augmentedMessages = new List<ChatMessage>(messageList.Count + 1) { systemMessage };
augmentedMessages.AddRange(messageList);
await foreach (var update in InnerAgent.RunStreamingAsync(augmentedMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <summary>
/// Reads the forwarded config triple from the AG-UI shared-state payload
/// attached to the run options. Any missing / unrecognized value falls
/// back to the corresponding default constant. Never throws.
/// </summary>
internal static (string Tone, string Expertise, string ResponseLength) ReadConfig(AgentRunOptions? options)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } ||
!properties.TryGetValue("ag_ui_state", out JsonElement state) ||
state.ValueKind != JsonValueKind.Object)
{
return (DefaultTone, DefaultExpertise, DefaultResponseLength);
}
var tone = ReadStringProperty(state, "tone", ValidTones, DefaultTone);
var expertise = ReadStringProperty(state, "expertise", ValidExpertise, DefaultExpertise);
var responseLength = ReadStringProperty(state, "responseLength", ValidResponseLengths, DefaultResponseLength);
return (tone, expertise, responseLength);
}
private static string ReadStringProperty(JsonElement state, string name, HashSet<string> valid, string defaultValue)
{
if (!state.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.String)
{
return defaultValue;
}
var value = element.GetString();
return value is not null && valid.Contains(value) ? value : defaultValue;
}
internal static string BuildSystemPrompt(string tone, string expertise, string responseLength)
{
var toneRule = tone switch
{
"casual" => "Use friendly, conversational language. Contractions OK. Light humor welcome.",
"enthusiastic" => "Use upbeat, energetic language. Exclamation points OK. Emoji OK.",
_ => "Use neutral, precise language. No emoji. Short sentences.",
};
var expertiseRule = expertise switch
{
"beginner" => "Assume no prior knowledge. Define jargon. Use analogies.",
"expert" => "Assume technical fluency. Use precise terminology. Skip basics.",
_ => "Assume common terms are understood; explain specialized terms.",
};
var lengthRule = responseLength switch
{
"detailed" => "Respond in multiple paragraphs with examples where relevant.",
_ => "Respond in 1-3 sentences.",
};
return "You are a helpful assistant.\n\n" +
$"Tone: {toneRule}\n" +
$"Expertise level: {expertiseRule}\n" +
$"Response length: {lengthRule}";
}
}
@@ -0,0 +1,50 @@
// STOPGAP: This integration-level header propagation replaces once copilotkit-sdk-dotnet
// ships (Microsoft contribution, ETA mid-2026). When that SDK lands, delete this code
// and use the SDK's built-in header propagation.
// See: https://www.notion.so/copilotkit/3543aa3818528150b6acc5b872ad7fe5
using Microsoft.AspNetCore.Http;
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
//
// The forwarded x-* headers are stashed in HttpContext.Items rather than an
// AsyncLocal. HttpContext flows across the AG-UI SSE-pump ExecutionContext
// boundary (the server seeds IHttpContextAccessor's holder at request entry,
// before any middleware, and all branches of the request's async tree share
// that holder), whereas a middleware-set AsyncLocal is captured in a snapshot
// that the deep outbound-LLM call site does NOT inherit. The previous
// AsyncLocal approach therefore lost the header at outbound-call time and the
// mock LLM server (aimock, strict mode) returned 503.
public static class AimockHeaderContext
{
// Key under which the filtered x-* header map is stored on HttpContext.Items.
private const string ItemsKey = "__aimock_forwarded_headers__";
/// <summary>
/// Stash the inbound x-* headers onto the request's HttpContext.Items so the
/// outbound policy can read them at LLM-call time, regardless of which
/// ExecutionContext branch the SSE pump is running on.
/// </summary>
public static void Set(HttpContext context, IDictionary<string, string> headers)
{
var filtered = headers
.Where(h => h.Key.StartsWith("x-", StringComparison.OrdinalIgnoreCase))
.ToDictionary(h => h.Key.ToLowerInvariant(), h => h.Value);
context.Items[ItemsKey] = filtered;
}
/// <summary>
/// Read the forwarded x-* headers for the current request via the supplied
/// HttpContext (resolved through IHttpContextAccessor at outbound-call time).
/// Returns an empty map when no headers were captured or no request is in scope.
/// </summary>
public static Dictionary<string, string> Get(HttpContext? context)
{
if (context?.Items.TryGetValue(ItemsKey, out var value) == true
&& value is IReadOnlyDictionary<string, string> headers)
{
return new Dictionary<string, string>(headers);
}
return new();
}
}
@@ -0,0 +1,37 @@
// STOPGAP: This integration-level header propagation replaces once copilotkit-sdk-dotnet
// ships (Microsoft contribution, ETA mid-2026). When that SDK lands, delete this code
// and use the SDK's built-in header propagation.
// See: https://www.notion.so/copilotkit/3543aa3818528150b6acc5b872ad7fe5
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
public class AimockHeaderMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AimockHeaderMiddleware> _logger;
public AimockHeaderMiddleware(RequestDelegate next, ILogger<AimockHeaderMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var headers = context.Request.Headers
.Where(h => h.Key.StartsWith("x-", StringComparison.OrdinalIgnoreCase))
.ToDictionary(h => h.Key, h => h.Value.ToString());
// Stash on HttpContext.Items (NOT an AsyncLocal): the value must survive
// the AG-UI SSE-pump ExecutionContext boundary so the outbound-LLM policy
// can read it via IHttpContextAccessor at call time.
AimockHeaderContext.Set(context, headers);
// CVDIAG inbound breadcrumb: the x-* headers (incl. x-diag-run-id /
// x-diag-hops / x-aimock-context) have now been captured onto
// HttpContext.Items for this request.
CvDiag.LogInbound(_logger, "backend-ms-agent-dotnet", AimockHeaderContext.Get(context));
// No finally-wipe: the captured headers are request-scoped — they live on
// this request's HttpContext and die with it. Wiping them in a finally
// raced the still-pumping SSE response and could clear the value before
// the outbound LLM call read it.
await _next(context);
}
}
@@ -0,0 +1,202 @@
// STOPGAP: This integration-level header propagation replaces once copilotkit-sdk-dotnet
// ships (Microsoft contribution, ETA mid-2026). When that SDK lands, delete this code
// and use the SDK's built-in header propagation.
// See: https://www.notion.so/copilotkit/3543aa3818528150b6acc5b872ad7fe5
using System.ClientModel.Primitives;
using Microsoft.AspNetCore.Http;
using OpenAI;
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
public class AimockHeaderPolicy : PipelinePolicy
{
// Seeded once at startup from Program.cs (where the DI container exists).
// The policy is created statically via CreateOpenAIClientOptions at
// agent-factory construction time and has no DI access, so it reads the
// request's HttpContext through this seeded singleton accessor — mirroring
// the CvDiag.Logger static-seed pattern. IHttpContextAccessor is a singleton
// that resolves the *current* request's HttpContext via a holder the server
// seeds at request entry; that holder flows across the AG-UI SSE-pump
// ExecutionContext boundary, so the headers the middleware stashed on
// HttpContext.Items are visible here at outbound-call time.
public static IHttpContextAccessor? HttpContextAccessor { get; set; }
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ApplyHeadersAndDiag(message);
var (backend, ctx, provider, model) = CvdiagLlmContext(message);
backend?.EmitLlmCallStart(ctx!, provider, model, EstimatePromptTokens(message));
var sw = System.Diagnostics.Stopwatch.StartNew();
string? errorClass = null;
try
{
ProcessNext(message, pipeline, currentIndex);
}
catch (Exception ex)
{
errorClass = ex.GetType().Name;
throw;
}
finally
{
sw.Stop();
backend?.EmitLlmCallResponse(ctx!, provider, model, null, sw.ElapsedMilliseconds, errorClass);
}
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ApplyHeadersAndDiag(message);
var (backend, ctx, provider, model) = CvdiagLlmContext(message);
backend?.EmitLlmCallStart(ctx!, provider, model, EstimatePromptTokens(message));
var sw = System.Diagnostics.Stopwatch.StartNew();
string? errorClass = null;
// Heartbeat: emit backend.llm.call.heartbeat every 10s while the outbound
// call is outstanding (spec §3; verbose-tier-and-above). The loop is a
// no-op when CVDIAG is off (backend null) — we skip starting it entirely.
using var heartbeatCts = new CancellationTokenSource();
Task? heartbeat = backend is null ? null : HeartbeatLoop(backend, ctx!, sw, heartbeatCts.Token);
try
{
await ProcessNextAsync(message, pipeline, currentIndex);
}
catch (Exception ex)
{
errorClass = ex.GetType().Name;
throw;
}
finally
{
sw.Stop();
heartbeatCts.Cancel();
if (heartbeat is not null)
{
try { await heartbeat; } catch (OperationCanceledException) { /* expected */ }
}
backend?.EmitLlmCallResponse(ctx!, provider, model, null, sw.ElapsedMilliseconds, errorClass);
}
}
private static async Task HeartbeatLoop(CvdiagBackend backend, CvdiagBackend.RequestContext ctx,
System.Diagnostics.Stopwatch sw, CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10), token);
backend.EmitLlmCallHeartbeat(ctx, sw.ElapsedMilliseconds);
}
}
catch (OperationCanceledException)
{
// Outbound call completed; stop heartbeating.
}
}
// Resolve the CVDIAG backend + per-request context + outbound provider/model
// at LLM-call time. Returns a null backend when CVDIAG is off so callers
// skip every emit. The request context is read via the same seeded
// IHttpContextAccessor the header forwarding uses.
private static (CvdiagBackend? Backend, CvdiagBackend.RequestContext? Ctx, string Provider, string Model)
CvdiagLlmContext(PipelineMessage message)
{
var backend = CvdiagBackend.Instance;
if (backend is null || !backend.IsEnabled) return (null, null, "openai", "unknown");
var ctx = CvdiagBackend.CurrentRequestContext;
if (ctx is null) return (null, null, "openai", "unknown");
var host = message.Request.Uri?.Host ?? "";
var provider = host.Contains("openai", StringComparison.OrdinalIgnoreCase) ? "openai"
: host.Contains("azure", StringComparison.OrdinalIgnoreCase) ? "azure"
: "openai";
var model = ExtractModel(message) ?? "unknown";
return (backend, ctx, provider, model);
}
// Best-effort: pull "model":"..." out of the outbound chat-completions body
// without fully parsing it (the body is a BinaryContent we must not consume).
private static string? ExtractModel(PipelineMessage message)
{
try
{
var content = message.Request.Content;
if (content is null) return null;
using var ms = new MemoryStream();
content.WriteTo(ms, default);
var json = System.Text.Encoding.UTF8.GetString(ms.ToArray());
var marker = "\"model\":\"";
var i = json.IndexOf(marker, StringComparison.Ordinal);
if (i < 0) return null;
var start = i + marker.Length;
var end = json.IndexOf('"', start);
return end > start ? json[start..end] : null;
}
catch
{
return null;
}
}
// Rough prompt-token estimate (~4 chars/token) over the outbound body size.
private static int EstimatePromptTokens(PipelineMessage message)
{
try
{
var content = message.Request.Content;
if (content is null) return 0;
using var ms = new MemoryStream();
content.WriteTo(ms, default);
return (int)(ms.Length / 4);
}
catch
{
return 0;
}
}
// Forwards the captured x-* headers onto the outbound LLM request and emits
// the CVDIAG outbound breadcrumb. The headers are read from the current
// request's HttpContext.Items via IHttpContextAccessor — HttpContext flows
// across the AG-UI SSE-pump ExecutionContext boundary, so the value the
// middleware stashed is still visible here at outbound-call time. This layer
// appends its hop tag to x-diag-hops on the outbound call.
private static void ApplyHeadersAndDiag(PipelineMessage message)
{
var headers = AimockHeaderContext.Get(HttpContextAccessor?.HttpContext);
foreach (var header in headers)
{
if (string.Equals(header.Key, CvDiag.HeaderDiagHops, StringComparison.OrdinalIgnoreCase))
continue; // set once below with this layer's hop appended
message.Request.Headers.Set(header.Key, header.Value);
}
// GATING RULE: only deviate from original control flow (append the
// x-diag-hops breadcrumb, emit the per-outbound CVDIAG log) when a
// diagnostic header is actually present. On non-diagnostic traffic the
// outbound request stays byte-identical to pre-instrumentation behavior
// (the inbound x-* forward loop above is original behavior).
bool diagnosticPresent = headers.ContainsKey(CvDiag.HeaderDiagRunId)
|| headers.ContainsKey(CvDiag.HeaderAimockContext);
if (diagnosticPresent)
{
headers.TryGetValue(CvDiag.HeaderDiagHops, out var existingHops);
message.Request.Headers.Set(CvDiag.HeaderDiagHops, CvDiag.AppendHop(existingHops, "backend-ms-agent-dotnet"));
CvDiag.LogOutbound("backend-ms-agent-dotnet", headers, CvDiag.HopCount(existingHops));
}
}
/// <summary>
/// Creates an <see cref="OpenAIClientOptions"/> with the header forwarding policy
/// pre-configured. All OpenAI client instantiations should use this to ensure
/// x-* prefixed headers propagate to outgoing calls.
/// </summary>
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
public static OpenAIClientOptions CreateOpenAIClientOptions(string endpoint)
{
var options = new OpenAIClientOptions
{
Endpoint = new Uri(endpoint),
};
options.AddPolicy(new AimockHeaderPolicy(), PipelinePosition.PerCall);
return options;
}
}
@@ -0,0 +1,423 @@
using System.ComponentModel;
using System.ClientModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using OpenAI;
// ============================================================================
// Beautiful Chat Agent
// ============================================================================
//
// Flagship showcase cell — simultaneously exercises A2UI (fixed + dynamic
// schema), Open Generative UI, and MCP Apps. The LangGraph reference lives at
// `showcase/integrations/langgraph-python/src/agents/beautiful_chat.py`; the tool
// set here is a near-verbatim port:
//
// - `query_data` / `get_todos` / `manage_todos` — backend tools that power
// the "Task Manager (Shared State)" and "Sales Dashboard" suggestion pills.
// - `search_flights` — A2UI fixed-schema tool. Returns `a2ui_operations`
// that create + populate a flight surface using the dashboard catalog.
// - `generate_a2ui` — A2UI dynamic-schema tool. Secondary LLM call designs
// a full dashboard UI on the fly.
// - `get_weather` — generic tool kept for parity with the rest of the
// showcase (rendered via `useDefaultRenderTool` on the frontend).
//
// OGUI + MCP are configured on the runtime side (see
// `src/app/api/copilotkit-beautiful-chat/route.ts`) — the agent itself
// doesn't need to know about them, which keeps this file focused on the
// tool surface the LLM actually calls.
//
// State: todos live in an in-memory store scoped to the factory instance.
// Matches the LangGraph reference's use of `Command(update=...)` — the
// frontend is the source of truth for edits, and the agent just mirrors the
// latest snapshot so tool-call roundtrips don't drop state.
// ============================================================================
internal sealed class BeautifulChatTodo
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("title")]
public string Title { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("emoji")]
public string Emoji { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "pending"; // "pending" | "completed"
}
internal sealed class BeautifulChatFlight
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("airline")]
public string Airline { get; set; } = "";
[JsonPropertyName("airlineLogo")]
public string AirlineLogo { get; set; } = "";
[JsonPropertyName("flightNumber")]
public string FlightNumber { get; set; } = "";
[JsonPropertyName("origin")]
public string Origin { get; set; } = "";
[JsonPropertyName("destination")]
public string Destination { get; set; } = "";
[JsonPropertyName("date")]
public string Date { get; set; } = "";
[JsonPropertyName("departureTime")]
public string DepartureTime { get; set; } = "";
[JsonPropertyName("arrivalTime")]
public string ArrivalTime { get; set; } = "";
[JsonPropertyName("duration")]
public string Duration { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("statusIcon")]
public string StatusIcon { get; set; } = "";
[JsonPropertyName("price")]
public string Price { get; set; } = "";
}
internal sealed class BeautifulChatAgentFactory
{
private const string CatalogId = "copilotkit://app-dashboard-catalog";
private const string FlightSurfaceId = "flight-search-results";
// Sample financial data mirrors beautiful_chat_data/db.csv on the
// LangGraph side. Kept inline so the .NET cell is self-contained — no
// extra data files to ship alongside the binary.
private static readonly object[] _sampleFinancialData = BuildSampleFinancialData();
private readonly OpenAIClient _openAiClient;
private readonly IConfiguration _configuration;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
private readonly List<BeautifulChatTodo> _todos = new();
private readonly object _todosLock = new();
public BeautifulChatAgentFactory(
IConfiguration configuration,
OpenAIClient openAiClient,
JsonSerializerOptions jsonSerializerOptions,
ILogger<BeautifulChatAgentFactory> logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(openAiClient);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
ArgumentNullException.ThrowIfNull(logger);
_configuration = configuration;
_openAiClient = openAiClient;
_jsonSerializerOptions = jsonSerializerOptions;
_logger = logger;
}
public AIAgent Create()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
var chatClientAgent = new ChatClientAgent(
chatClient,
name: "BeautifulChatAgent",
description: @"You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
Tool guidance:
- Flights: call search_flights to show flight cards with a pre-built schema. Return exactly 2 flights.
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
charts, tables, and cards. It handles rendering automatically.
- Charts (frontend components): call query_data first, then render with the
pieChart or barChart frontend component.
- Todos: enable app mode first (call enableAppMode), then manage todos.
- A2UI actions: when you see a log_a2ui_event result (e.g. ""view_details""),
respond with a brief confirmation. The UI already updated on the frontend.",
tools: [
AIFunctionFactory.Create(QueryData, options: new() { Name = "query_data", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetTodos, options: new() { Name = "get_todos", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(ManageTodos, options: new() { Name = "manage_todos", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetWeather, options: new() { Name = "get_weather", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(SearchFlights, options: new() { Name = "search_flights", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GenerateA2ui, options: new() { Name = "generate_a2ui", SerializerOptions = _jsonSerializerOptions })
]);
return new BeautifulChatStateSnapshotAgent(chatClientAgent, this, _jsonSerializerOptions, _logger);
}
// ─── Tools ──────────────────────────────────────────────────────
[Description("Query the database, takes natural language. Always call before showing a chart or graph.")]
private object QueryData([Description("The natural-language query to run")] string query)
{
_logger.LogInformation("[beautiful-chat] query_data: {Query}", query);
return _sampleFinancialData;
}
[Description("Get the current todos.")]
private List<BeautifulChatTodo> GetTodos()
{
return GetTodosSnapshot();
}
internal List<BeautifulChatTodo> GetTodosSnapshot()
{
lock (_todosLock)
{
// Return a defensive copy so callers can't mutate our backing list.
return _todos.Select(t => new BeautifulChatTodo
{
Id = t.Id,
Title = t.Title,
Description = t.Description,
Emoji = t.Emoji,
Status = t.Status,
}).ToList();
}
}
[Description("Manage the current todos. Provide the full desired list of todos; any missing ids are assigned a fresh uuid.")]
private string ManageTodos([Description("The updated list of todos")] List<BeautifulChatTodo> todos)
{
ArgumentNullException.ThrowIfNull(todos);
lock (_todosLock)
{
_todos.Clear();
foreach (var todo in todos)
{
if (string.IsNullOrEmpty(todo.Id))
{
todo.Id = Guid.NewGuid().ToString();
}
_todos.Add(todo);
}
}
_logger.LogInformation("[beautiful-chat] manage_todos: {Count} items", todos.Count);
return "Successfully updated todos";
}
[Description("Get the weather for a given location. Ensure location is fully spelled out.")]
private WeatherInfo GetWeather([Description("The location to get the weather for")] string location)
{
_logger.LogInformation("[beautiful-chat] get_weather: {Location}", location);
return new WeatherInfo
{
City = location,
Temperature = 20,
Conditions = "sunny",
Humidity = 50,
WindSpeed = 10,
FeelsLike = 25,
};
}
[Description(@"Search for flights and display the results as rich cards. Return exactly 2 flights.
Each flight must have: id, airline (e.g. ""United Airlines""),
airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128),
flightNumber, origin, destination,
date (short readable format like ""Tue, Mar 18""),
departureTime, arrivalTime, duration (e.g. ""4h 25m""),
status (e.g. ""On Time"" or ""Delayed""),
statusIcon (colored dot: ""https://placehold.co/12/22c55e/22c55e.png"" for On Time,
""https://placehold.co/12/eab308/eab308.png"" for Delayed),
price (e.g. ""$289"").")]
private object SearchFlights([Description("The list of flights to render")] List<BeautifulChatFlight> flights)
{
ArgumentNullException.ThrowIfNull(flights);
_logger.LogInformation("[beautiful-chat] search_flights: {Count}", flights.Count);
// Flat literal-children layout — mirrors the LangGraph reference's
// `_build_flight_components`. We avoid the structural-children
// template form (Row.children = { componentId, path }) because the
// GenericBinder only expands templates correctly for components
// whose schema declares STRUCTURAL children — sibling demos work
// because their schemas use literal-string-array children. Inlining
// the values per-flight sidesteps the template path entirely and
// renders identically.
var components = new List<object>();
var flightCardIds = new List<string>();
for (int i = 0; i < flights.Count; i++)
{
var flight = flights[i];
var cardId = $"flight-card-{i}";
flightCardIds.Add(cardId);
components.Add(new
{
id = cardId,
component = "FlightCard",
airline = flight.Airline,
airlineLogo = flight.AirlineLogo,
flightNumber = flight.FlightNumber,
origin = flight.Origin,
destination = flight.Destination,
date = flight.Date,
departureTime = flight.DepartureTime,
arrivalTime = flight.ArrivalTime,
duration = flight.Duration,
status = flight.Status,
price = flight.Price,
});
}
var root = new
{
id = "root",
component = "Row",
children = flightCardIds,
gap = 16,
};
var allComponents = new List<object> { root };
allComponents.AddRange(components);
var operations = new object[]
{
new { version = "v0.9", createSurface = new { surfaceId = FlightSurfaceId, catalogId = CatalogId } },
new { version = "v0.9", updateComponents = new { surfaceId = FlightSurfaceId, components = allComponents } },
};
return new { a2ui_operations = operations };
}
[Description("Generate dynamic A2UI components based on the conversation. A secondary LLM designs the UI schema and data.")]
private async Task<object> GenerateA2ui(
[Description("Conversation context to generate UI from.")] string context = "",
CancellationToken cancellationToken = default)
{
context ??= "";
var errorId = Guid.NewGuid().ToString("n")[..16];
var userContent = string.IsNullOrWhiteSpace(context)
? "Show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales."
: context;
_logger.LogInformation("[beautiful-chat] generate_a2ui (errorId={ErrorId}) for: {Request}", errorId, userContent);
string? content;
try
{
content = await A2uiSecondaryToolCaller.GetDesignToolArgumentsAsync(
_configuration,
"Generate a useful A2UI dashboard.",
userContent,
cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "[beautiful-chat] generate_a2ui (errorId={ErrorId}): transport failure", errorId);
return SalesAgentFactory.StructuredError("upstream_unavailable", "The upstream AI service is currently unreachable. Please retry.", "Retry the request in a few seconds.", errorId);
}
catch (ClientResultException ex)
{
_logger.LogError(ex, "[beautiful-chat] generate_a2ui (errorId={ErrorId}): upstream returned error status {Status}", errorId, ex.Status);
return SalesAgentFactory.StructuredError("upstream_error", "The upstream AI service returned an error.", "Try rephrasing the request or retrying later.", errorId);
}
catch (OperationCanceledException)
{
throw;
}
if (string.IsNullOrEmpty(content))
{
_logger.LogError("[beautiful-chat] generate_a2ui (errorId={ErrorId}): empty response", errorId);
return new { error = "empty_llm_output", errorId };
}
// Reuse the SalesAgentFactory's A2UI response builder so the JSON
// massaging is shared across both demos and we get the same structured
// error taxonomy for free.
return SalesAgentFactory.BuildA2uiResponseFromContent(content, errorId, _logger);
}
// ─── Sample data (inline port of beautiful_chat_data/db.csv) ───
private static object[] BuildSampleFinancialData()
{
// Representative slice of the Python demo's db.csv. We keep it small
// but varied so charts look alive — not a 1:1 row-count port.
return new object[]
{
new { date = "2026-01-05", category = "Revenue", subcategory = "Enterprise Subscriptions", amount = 28000, type = "income" },
new { date = "2026-01-05", category = "Revenue", subcategory = "Pro Tier Upgrades", amount = 18000, type = "income" },
new { date = "2026-01-08", category = "Revenue", subcategory = "API Usage Overages", amount = 9500, type = "income" },
new { date = "2026-01-10", category = "Expenses", subcategory = "Engineering Salaries", amount = 42000, type = "expense" },
new { date = "2026-01-10", category = "Expenses", subcategory = "Product Team", amount = 18000, type = "expense" },
new { date = "2026-01-12", category = "Expenses", subcategory = "AWS Infrastructure", amount = 8200, type = "expense" },
new { date = "2026-01-15", category = "Expenses", subcategory = "Marketing - Paid Ads", amount = 12000, type = "expense" },
new { date = "2026-01-18", category = "Revenue", subcategory = "Consulting Services", amount = 14500, type = "income" },
new { date = "2026-01-20", category = "Expenses", subcategory = "Customer Success", amount = 15000, type = "expense" },
new { date = "2026-01-22", category = "Expenses", subcategory = "AI Model Costs", amount = 4200, type = "expense" },
new { date = "2026-01-25", category = "Revenue", subcategory = "Marketplace Sales", amount = 12800, type = "income" },
new { date = "2026-02-03", category = "Revenue", subcategory = "Enterprise Subscriptions", amount = 31000, type = "income" },
new { date = "2026-02-03", category = "Revenue", subcategory = "Pro Tier Upgrades", amount = 22500, type = "income" },
new { date = "2026-02-10", category = "Expenses", subcategory = "Engineering Salaries", amount = 44000, type = "expense" },
new { date = "2026-02-15", category = "Revenue", subcategory = "Consulting Services", amount = 17200, type = "income" },
new { date = "2026-03-05", category = "Revenue", subcategory = "Enterprise Subscriptions", amount = 35500, type = "income" },
new { date = "2026-03-10", category = "Expenses", subcategory = "Engineering Salaries", amount = 46000, type = "expense" },
new { date = "2026-03-15", category = "Revenue", subcategory = "Marketplace Sales", amount = 15800, type = "income" },
};
}
}
internal sealed class BeautifulChatStateSnapshotAgent : DelegatingAIAgent
{
private readonly BeautifulChatAgentFactory _factory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
public BeautifulChatStateSnapshotAgent(
AIAgent innerAgent,
BeautifulChatAgentFactory factory,
JsonSerializerOptions jsonSerializerOptions,
ILogger logger)
: base(innerAgent)
{
_factory = factory;
_jsonSerializerOptions = jsonSerializerOptions;
_logger = logger;
}
public override Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
var snapshot = new Dictionary<string, object?> { ["todos"] = _factory.GetTodosSnapshot() };
var snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot, _jsonSerializerOptions);
_logger.LogDebug("[beautiful-chat] emitting todos state snapshot ({Bytes} bytes)", snapshotBytes.Length);
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(snapshotBytes, "application/json")],
};
}
}
@@ -0,0 +1,115 @@
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
/// <summary>
/// Factory for the byoc-hashbrown demo agent.
///
/// This agent emits a hashbrown-shaped JSON envelope that the
/// frontend renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`)
/// progressively parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
///
/// Wire format: `@hashbrownai/react`'s `useJsonParser(content, kit.schema)`
/// expects a JSON object matching `kit.schema` -- NOT the `&lt;ui&gt;...&lt;/ui&gt;`
/// XML-style examples shown inside `useUiKit({ examples })`. Those XML
/// examples are the hashbrown prompt DSL only used when hashbrown drives
/// the LLM directly; because this demo drives via the Microsoft Agent
/// Framework, the agent must emit the schema wire format instead:
///
/// { "ui": [ { "metric": { "props": { "label": "...", "value": "..." } } }, ... ] }
///
/// Every node is a single-key object `{tagName: {props: {...}}}`.
/// `pieChart` and `barChart` receive `data` as a JSON-encoded string.
/// </summary>
public class ByocHashbrownAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt = @"You are a sales analytics assistant that replies by emitting a single JSON
object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
""ui"": [
{ <componentName>: { ""props"": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or
explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- ""metric"": { ""props"": { ""label"": string, ""value"": string } }
A KPI card. `value` is a pre-formatted string like ""$1.2M"" or ""248"".
- ""pieChart"": { ""props"": { ""title"": string, ""data"": string } }
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
array of {label, value} objects with at least 3 segments, e.g.
""data"": ""[{\""label\"":\""Enterprise\"",\""value\"":600000}]"".
- ""barChart"": { ""props"": { ""title"": string, ""data"": string } }
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
{label, value} objects with at least 3 bars, typically time-ordered.
- ""dealCard"": { ""props"": { ""title"": string, ""stage"": string, ""value"": number } }
A single sales deal. `stage` MUST be one of: ""prospect"", ""qualified"",
""proposal"", ""negotiation"", ""closed-won"", ""closed-lost"". `value` is a
raw number (no currency symbol or comma).
- ""Markdown"": { ""props"": { ""children"": string } }
Short explanatory text. Use for section headings and brief summaries.
Standard markdown is supported in `children`.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or
chart — do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Use ""Markdown"" for short headings or linking sentences between visual
components. Do not emit long prose.
- Do not emit components that are not listed above.
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
Example response (sales dashboard):
{""ui"":[{""Markdown"":{""props"":{""children"":""## Q4 Sales Summary""}}},{""metric"":{""props"":{""label"":""Total Revenue"",""value"":""$1.2M""}}},{""metric"":{""props"":{""label"":""New Customers"",""value"":""248""}}},{""pieChart"":{""props"":{""title"":""Revenue by Segment"",""data"":""[{\""label\"":\""Enterprise\"",\""value\"":600000},{\""label\"":\""SMB\"",\""value\"":400000},{\""label\"":\""Startup\"",\""value\"":200000}]""}}},{""barChart"":{""props"":{""title"":""Monthly Revenue"",""data"":""[{\""label\"":\""Oct\"",\""value\"":350000},{\""label\"":\""Nov\"",\""value\"":400000},{\""label\"":\""Dec\"",\""value\"":450000}]""}}}]}";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
public ByocHashbrownAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger<ByocHashbrownAgentFactory>();
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_logger.LogInformation("ByocHashbrownAgent using OpenAI endpoint: {Endpoint}", endpoint);
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// `description` on ChatClientAgent is passed to the chat client as the
// system-instruction equivalent, so it steers the model to emit a
// single <ui>...</ui> envelope for every response.
return new ChatClientAgent(
chatClient,
name: "ByocHashbrownAgent",
description: SystemPrompt);
}
}
@@ -0,0 +1,183 @@
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
/// <summary>
/// Factory for the byoc-json-render demo agent.
///
/// Emits a single JSON object shaped like `@json-render/react`'s flat spec
/// format (`{ root, elements }`) so the frontend can feed it directly into
/// `<Renderer />` against a Zod-validated catalog of three components —
/// MetricCard, BarChart, PieChart.
///
/// Mirrors `src/agents/byoc_json_render_agent.py` in the langgraph-python
/// showcase — same system prompt, same component catalog.
/// </summary>
public class ByocJsonRenderAgentFactory
{
private const string SystemPrompt = @"You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and
nothing else — no prose, no markdown fences, no leading explanation. The
object must match this schema (the ""flat element map"" format consumed by
`@json-render/react`):
{
""root"": ""<id of the root element>"",
""elements"": {
""<id>"": {
""type"": ""<component name>"",
""props"": { ... component-specific props ... },
""children"": [ ""<id>"", ... ]
},
...
}
}
Available components (use each name verbatim as ""type""):
- MetricCard
props: { ""label"": string, ""value"": string, ""trend"": string | null }
Example trend strings: ""+12% vs last quarter"", ""-3% vs last month"", null.
- BarChart
props: {
""title"": string,
""description"": string | null,
""data"": [ { ""label"": string, ""value"": number }, ... ]
}
- PieChart
props: {
""title"": string,
""description"": string | null,
""data"": [ { ""label"": string, ""value"": number }, ... ]
}
Rules:
1. Output **only** valid JSON. No markdown code fences. No text outside
the object.
2. Every id referenced in `root` or any `children` array must be a key
in `elements`.
3. For a multi-component dashboard, use a root MetricCard and list the
charts in its `children` array, OR pick any element as root and list
the others as its children. Do not emit orphan elements.
4. Use realistic sales-domain values (revenue, pipeline, conversion,
categories, months) — the demo is a sales dashboard.
5. `children` is optional but when present must be an array of strings.
6. Never invent component types outside the three listed above.
### Worked example — ""Show me the sales dashboard with metrics and a revenue chart""
{
""root"": ""revenue-metric"",
""elements"": {
""revenue-metric"": {
""type"": ""MetricCard"",
""props"": {
""label"": ""Revenue (Q3)"",
""value"": ""$1.24M"",
""trend"": ""+18% vs Q2""
},
""children"": [""revenue-bar""]
},
""revenue-bar"": {
""type"": ""BarChart"",
""props"": {
""title"": ""Monthly revenue"",
""description"": ""Revenue by month across Q3"",
""data"": [
{ ""label"": ""Jul"", ""value"": 380000 },
{ ""label"": ""Aug"", ""value"": 410000 },
{ ""label"": ""Sep"", ""value"": 450000 }
]
}
}
}
}
### Worked example — ""Break down revenue by category as a pie chart""
{
""root"": ""category-pie"",
""elements"": {
""category-pie"": {
""type"": ""PieChart"",
""props"": {
""title"": ""Revenue by category"",
""description"": ""Share of total revenue by product category"",
""data"": [
{ ""label"": ""Enterprise"", ""value"": 540000 },
{ ""label"": ""SMB"", ""value"": 310000 },
{ ""label"": ""Self-serve"", ""value"": 220000 },
{ ""label"": ""Partner"", ""value"": 170000 }
]
}
}
}
}
### Worked example — ""Show me monthly expenses as a bar chart""
{
""root"": ""expense-bar"",
""elements"": {
""expense-bar"": {
""type"": ""BarChart"",
""props"": {
""title"": ""Monthly expenses"",
""description"": ""Operating expenses by month"",
""data"": [
{ ""label"": ""Jul"", ""value"": 210000 },
{ ""label"": ""Aug"", ""value"": 225000 },
{ ""label"": ""Sep"", ""value"": 240000 }
]
}
}
}
}
Respond with the JSON object only.";
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
public ByocJsonRenderAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger<ByocJsonRenderAgentFactory>();
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_logger.LogInformation("ByocJsonRenderAgent using OpenAI endpoint: {Endpoint}", endpoint);
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// The frontend json-render-renderer.tsx buffers until the assistant
// content parses as a complete JSON object, then renders. The agent
// is steered to emit a single JSON object per reply by the
// SystemPrompt above.
return new ChatClientAgent(
chatClient,
name: "ByocJsonRenderAgent",
description: SystemPrompt);
}
}
@@ -0,0 +1,82 @@
// STOPGAP: Cross-language header-forwarding diagnostic ("CVDIAG") instrumentation.
// Emits a single-line, machine-greppable breadcrumb at the inbound capture
// boundary (AimockHeaderMiddleware) and the outbound-LLM boundary
// (AimockHeaderPolicy) so a request's x-aimock-context / x-diag-run-id /
// x-diag-hops correlation headers can be traced as they cross the AsyncLocal
// handoff. The line format is shared verbatim with the other language
// integrations (Java/TS/Python) so logs join on run_id.
//
// Instrumentation only: never alters request behavior. Full header values are
// never logged — only a 12-char prefix. Mirrors the Java CvDiag helper.
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
using Microsoft.Extensions.Logging;
public static class CvDiag
{
public const string HeaderAimockContext = "x-aimock-context";
public const string HeaderDiagRunId = "x-diag-run-id";
public const string HeaderDiagHops = "x-diag-hops";
public const string HeaderTestId = "x-test-id";
// Seeded once at startup from Program.cs (where an ILoggerFactory exists).
// The outbound boundary (AimockHeaderPolicy) is created statically without
// DI access to a logger, so it reads this. Null-safe: if unset, outbound
// logging is skipped (instrumentation must never throw).
public static ILogger? Logger { get; set; }
/// <summary>Logs the CVDIAG breadcrumb for the inbound capture boundary.</summary>
public static void LogInbound(ILogger logger, string component, IReadOnlyDictionary<string, string> headers)
{
logger.LogInformation("{Line}", Line(component, "inbound", headers, "-", Status(headers)));
}
/// <summary>Logs the CVDIAG breadcrumb for the outbound-LLM boundary via the seeded static logger.</summary>
public static void LogOutbound(string component, IReadOnlyDictionary<string, string> headers, int hop)
{
Logger?.LogInformation("{Line}", Line(component, "outbound-llm", headers, hop.ToString(), Status(headers)));
}
/// <summary>Returns the new x-diag-hops value after appending this layer's tag.</summary>
public static string AppendHop(string? existingHops, string tag)
{
return string.IsNullOrWhiteSpace(existingHops) ? tag : existingHops + "," + tag;
}
/// <summary>Number of hops present on x-diag-hops after this layer appends.</summary>
public static int HopCount(string? existingHops)
{
return string.IsNullOrWhiteSpace(existingHops) ? 1 : existingHops.Split(',').Length + 1;
}
private static string Status(IReadOnlyDictionary<string, string> headers)
=> Present(headers, HeaderAimockContext) ? "ok" : "miss";
private static bool Present(IReadOnlyDictionary<string, string> headers, string key)
=> headers.TryGetValue(key, out var v) && !string.IsNullOrEmpty(v);
private static string ValueOr(IReadOnlyDictionary<string, string> headers, string key, string fallback)
=> headers.TryGetValue(key, out var v) && !string.IsNullOrEmpty(v) ? v : fallback;
private static string Prefix(IReadOnlyDictionary<string, string> headers, string key)
{
if (!headers.TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) return "";
return v.Length <= 12 ? v : v.Substring(0, 12);
}
private static string Line(string component, string boundary,
IReadOnlyDictionary<string, string> headers, string hop, string status)
{
return "CVDIAG"
+ " component=" + component
+ " boundary=" + boundary
+ " run_id=" + ValueOr(headers, HeaderDiagRunId, "none")
+ " slug=" + ValueOr(headers, HeaderAimockContext, "MISSING")
+ " header_present=" + (Present(headers, HeaderAimockContext) ? "true" : "false")
+ " header_value_prefix=" + Prefix(headers, HeaderAimockContext)
+ " hop=" + hop
+ " status=" + status
+ " test_id=" + ValueOr(headers, HeaderTestId, "none")
+ " error=";
}
}
@@ -0,0 +1,438 @@
// CvdiagBackend.cs — backend-layer CVDIAG instrumentation for ms-agent-dotnet
// (plan unit L1-F; spec §3 backend boundaries + §6 tier matrix). Wires the 11
// backend boundaries through the shared CvdiagEmitter (source-included from
// _shared/dotnet/) so this integration emits the same flap-observability
// envelope as the Python / TS / Java backends.
//
// The 11 backend boundaries (spec §3):
// backend.request.ingress — HTTP request received (AimockHeaderMiddleware)
// backend.agent.enter — agent loop entered (request scope, pre-pump)
// backend.llm.call.start — outbound LLM call dispatched (AimockHeaderPolicy)
// backend.llm.call.heartbeat — every 10s while an LLM call is outstanding
// backend.llm.call.response — LLM response received (policy, post-pipeline)
// backend.sse.first_byte — first byte written to the response stream
// backend.sse.event — every SSE event written (debug tier only)
// backend.sse.aborted — stream terminated abnormally
// backend.agent.exit — agent loop exited (request scope, post-pump)
// backend.response.complete — response finished (status, bytes, duration)
// backend.error.caught — exception caught in the request pipeline
//
// FAIL-CLOSED / OFF BY DEFAULT: the whole layer is gated by the
// CVDIAG_BACKEND_EMITTER env (default "off"). When off, IsEnabled is false and
// every Emit* method is a no-op — zero behavioral change on the request path.
// Pure instrumentation: a CVDIAG failure must NEVER throw into the observed
// boundary (the shared CvdiagEmitter swallows hot-path errors to stderr).
using System.Diagnostics;
using Copilotkit.Showcase.Cvdiag;
using Microsoft.AspNetCore.Http;
// TODO(copilotkit-sdk-dotnet): fold into SDK-level observability when it ships.
public sealed class CvdiagBackend
{
private const string SlugHeader = "x-aimock-context";
private const string TestIdHeader = "x-test-id";
public const string EnabledEnv = "CVDIAG_BACKEND_EMITTER";
// Seeded once at startup from Program.cs (mirrors the CvDiag.Logger /
// AimockHeaderPolicy.HttpContextAccessor static-seed pattern). The outbound
// policy is created without DI, so it reads this static singleton.
public static CvdiagBackend? Instance { get; set; }
private readonly CvdiagEmitter? _emitter;
/// <summary>True iff CVDIAG_BACKEND_EMITTER is "on" (default off → null emitter).</summary>
public bool IsEnabled => _emitter is not null;
public CvdiagBackend(IReadOnlyDictionary<string, string?>? env = null)
{
env ??= ReadProcessEnv();
var flag = env.GetValueOrDefault(EnabledEnv);
if (!string.Equals(flag, "on", StringComparison.OrdinalIgnoreCase))
{
_emitter = null; // OFF by default → no-op layer.
return;
}
var pbWriteUrl = env.GetValueOrDefault("CVDIAG_PB_WRITE_URL");
_emitter = new CvdiagEmitter(new CvdiagEmitterOptions
{
Layer = CvdiagLayer.Backend,
Env = env,
PbWriteUrl = string.IsNullOrEmpty(pbWriteUrl) ? null : pbWriteUrl,
});
}
// ── Per-request correlation context ──────────────────────────────────────
//
// Resolved once at ingress from the forwarded x-* headers (slug from
// x-aimock-context, test_id from x-test-id) and stashed on HttpContext.Items
// so the later boundaries (sse.*, response.complete, llm.call.*) reuse the
// same trace.
private const string CtxKey = "__cvdiag_backend_ctx__";
// Current-request context for the outbound-LLM boundary. The instrumentation
// middleware sets this at ingress; the LLM policy (which has no HttpContext
// in this integration's wiring) reads it. It flows on the request's async
// tree the same way AimockHeaderContext's headers do.
private static readonly AsyncLocal<RequestContext?> CurrentContext = new();
public sealed class RequestContext
{
public required string Slug { get; init; }
public required string Demo { get; init; }
public required string TestId { get; init; }
public long IngressMs { get; init; }
public int SseEventCount;
public bool FirstByteSeen;
}
/// <summary>
/// Resolve (or create) the per-request CVDIAG context from the forwarded
/// headers on the HttpContext. Slug ← x-aimock-context, demo ← request path,
/// test_id ← x-test-id (minted if absent so the trace is always well-formed).
/// Reading headers directly off HttpContext (rather than the integration's
/// AimockHeaderContext) keeps this identical across both .NET integrations.
/// </summary>
public RequestContext GetOrCreateContext(HttpContext context)
{
if (context.Items.TryGetValue(CtxKey, out var existing) && existing is RequestContext rc)
{
return rc;
}
var slug = HeaderValue(context, SlugHeader) ?? "unknown";
var testId = HeaderValue(context, TestIdHeader) ?? CvdiagEmitter.MintTestId();
var demo = context.Request.Path.HasValue
? context.Request.Path.Value!.Trim('/').Split('/').FirstOrDefault() ?? "default"
: "default";
if (string.IsNullOrEmpty(demo)) demo = "default";
var created = new RequestContext
{
Slug = slug,
Demo = demo,
TestId = testId,
IngressMs = NowMs(),
};
context.Items[CtxKey] = created;
CurrentContext.Value = created;
return created;
}
private static string? HeaderValue(HttpContext context, string name)
=> context.Request.Headers.TryGetValue(name, out var v) && !string.IsNullOrEmpty(v.ToString())
? v.ToString()
: null;
/// <summary>The current request's context for the outbound-LLM boundary.</summary>
public static RequestContext? CurrentRequestContext => CurrentContext.Value;
// ── The 11 backend boundaries ────────────────────────────────────────────
public void EmitRequestIngress(RequestContext ctx, HttpContext http)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendRequestIngress,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
EdgeHeaders = ExtractEdgeHeaders(http),
Metadata = new Dictionary<string, object?>
{
["method"] = http.Request.Method,
["path"] = http.Request.Path.Value ?? "",
["content_length"] = (int?)http.Request.ContentLength,
},
});
}
public void EmitAgentEnter(RequestContext ctx, string agentName, string modelId)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendAgentEnter,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["agent_name"] = agentName,
["model_id"] = modelId,
},
});
}
public void EmitLlmCallStart(RequestContext ctx, string provider, string model, int promptTokenEstimate)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendLlmCallStart,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["provider"] = provider,
["model"] = model,
["prompt_token_count_estimate"] = promptTokenEstimate,
},
});
}
public void EmitLlmCallHeartbeat(RequestContext ctx, long elapsedMsSinceStart)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendLlmCallHeartbeat,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["elapsed_ms_since_start"] = elapsedMsSinceStart,
},
});
}
public void EmitLlmCallResponse(RequestContext ctx, string provider, string model,
int? responseTokenCount, long latencyMs, string? errorClass)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendLlmCallResponse,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = errorClass is null ? CvdiagOutcome.Ok : CvdiagOutcome.Err,
TestId = ctx.TestId,
DurationMs = latencyMs,
Metadata = new Dictionary<string, object?>
{
["provider"] = provider,
["model"] = model,
["response_token_count"] = responseTokenCount,
["latency_ms"] = (int)latencyMs,
["error_class"] = errorClass,
},
});
}
public void EmitSseFirstByte(RequestContext ctx, long deltaMsFromIngress)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendSseFirstByte,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["delta_ms_from_ingress"] = deltaMsFromIngress,
},
});
}
public void EmitSseEvent(RequestContext ctx, string eventType, int payloadSizeBytes, int sequenceNum)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendSseEvent,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Info,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["event_type"] = eventType,
["payload_size_bytes"] = payloadSizeBytes,
["sequence_num"] = sequenceNum,
},
});
}
public void EmitSseAborted(RequestContext ctx, string terminationKind, int bytesBeforeAbort)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendSseAborted,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Err,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["termination_kind"] = terminationKind,
["bytes_before_abort"] = bytesBeforeAbort,
},
});
}
public void EmitAgentExit(RequestContext ctx, string terminalOutcome, long totalDurationMs)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendAgentExit,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = terminalOutcome == "ok" ? CvdiagOutcome.Ok : CvdiagOutcome.Err,
TestId = ctx.TestId,
DurationMs = totalDurationMs,
Metadata = new Dictionary<string, object?>
{
["terminal_outcome"] = terminalOutcome,
["total_duration_ms"] = totalDurationMs,
},
});
}
public void EmitResponseComplete(RequestContext ctx, int httpStatus, int contentLength,
long totalDurationMs, int sseEventCount)
{
if (_emitter is null) return;
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendResponseComplete,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = httpStatus is >= 200 and < 400 ? CvdiagOutcome.Ok : CvdiagOutcome.Err,
TestId = ctx.TestId,
DurationMs = totalDurationMs,
Metadata = new Dictionary<string, object?>
{
["http_status"] = httpStatus,
["content_length"] = contentLength,
["total_duration_ms"] = totalDurationMs,
["sse_event_count"] = sseEventCount,
},
});
}
public void EmitErrorCaught(RequestContext ctx, Exception ex)
{
if (_emitter is null) return;
var (stackBrief, truncated) = StackBrief(ex);
_emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendErrorCaught,
Slug = ctx.Slug,
Demo = ctx.Demo,
Outcome = CvdiagOutcome.Err,
TestId = ctx.TestId,
Metadata = new Dictionary<string, object?>
{
["exception_type"] = ex.GetType().FullName ?? ex.GetType().Name,
["message_scrubbed"] = Scrub(ex.Message),
["stack_brief"] = stackBrief,
["truncated"] = truncated,
},
});
}
// ── PII scrub (spec §10 / R6-F3) ─────────────────────────────────────────
//
// Redact secret-shaped tokens from any free-text we put on the wire
// (exception messages, etc.). Mirrors the probe/Python scrub: Bearer
// tokens, OpenAI-style sk-/sk-test- keys, and Authorization header values.
// Capped at 512 bytes per spec backend.error.caught message_scrubbed.
private static readonly System.Text.RegularExpressions.Regex[] ScrubPatterns =
{
new(@"(?i)bearer\s+[A-Za-z0-9._\-]+", System.Text.RegularExpressions.RegexOptions.Compiled),
new(@"sk-[A-Za-z0-9._\-]{8,}", System.Text.RegularExpressions.RegexOptions.Compiled),
new(@"(?i)authorization\s*[:=]\s*\S+", System.Text.RegularExpressions.RegexOptions.Compiled),
};
// URL userinfo authority segment — redact the credentials between
// `scheme://` and the LAST authority `@`, keeping the scheme and host.
// Mirrors scrubSecrets' URL_USERINFO_REGEX (harness/src/cvdiag/scrub.ts):
// covers `scheme://user:pass@host`, the colon-less `scheme://token@host`
// (e.g. `https://ghp_xxx@host`), and multi-`@` authorities. The userinfo
// class `[^/\s?#]*` excludes `?`/`#`/`/`/whitespace so the match can never
// cross into the path/query/fragment (R5-A2). Replacement: `$1[REDACTED]@`.
private static readonly System.Text.RegularExpressions.Regex UrlUserinfoPattern =
new(@"([a-z][a-z0-9+.\-]*://)[^/\s?#]*@",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Redact secret-shaped tokens and cap to 512 bytes (spec backend.error.caught).</summary>
public static string Scrub(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "";
var scrubbed = raw;
foreach (var pat in ScrubPatterns)
{
scrubbed = pat.Replace(scrubbed, "[REDACTED]");
}
scrubbed = UrlUserinfoPattern.Replace(scrubbed, "$1[REDACTED]@");
if (System.Text.Encoding.UTF8.GetByteCount(scrubbed) > 512)
{
scrubbed = scrubbed.Length > 509 ? scrubbed[..509] + "..." : scrubbed;
}
return scrubbed;
}
// ≤8 frames, each "file:line", PII-scrubbed; flags truncation past 8 frames.
private static (string Brief, bool Truncated) StackBrief(Exception ex)
{
var trace = new StackTrace(ex, fNeedFileInfo: true);
var frames = trace.GetFrames();
if (frames is null || frames.Length == 0)
{
return (Scrub(ex.StackTrace ?? ""), false);
}
var truncated = frames.Length > 8;
var lines = frames.Take(8).Select(f =>
{
var file = f.GetFileName();
var line = f.GetFileLineNumber();
var method = f.GetMethod()?.Name ?? "?";
return file is not null ? $"{file}:{line}" : method;
});
return (Scrub(string.Join(" <- ", lines)), truncated);
}
private static EdgeHeaders ExtractEdgeHeaders(HttpContext http)
{
var bag = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in http.Request.Headers)
{
bag[key] = value.ToString();
}
return CvdiagEmitter.FilterEdgeHeaders(bag);
}
public static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static IReadOnlyDictionary<string, string?> ReadProcessEnv()
{
var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables())
{
if (e.Key is string k) dict[k] = e.Value as string;
}
return dict;
}
}
@@ -0,0 +1,198 @@
// CvdiagInstrumentationMiddleware.cs — the request-pipeline CVDIAG boundaries
// for ms-agent-dotnet (plan unit L1-F; spec §3). Sits OUTSIDE MapAGUI (which
// owns the agent loop + SSE writing) and observes the request from the edge:
//
// • backend.request.ingress — at request entry (method/path/content-length)
// • backend.agent.enter — just before handing off to the pipeline
// • backend.sse.first_byte — first byte written to the response body
// • backend.sse.event — each "data:" SSE frame written (debug tier)
// • backend.sse.aborted — client/edge disconnect mid-stream
// • backend.agent.exit — after the pipeline returns
// • backend.response.complete — terminal status/bytes/duration/event-count
// • backend.error.caught — unhandled exception in the pipeline
//
// The remaining 3 boundaries (backend.llm.call.start/heartbeat/response) fire in
// AimockHeaderPolicy at the outbound-LLM boundary.
//
// OFF BY DEFAULT: when CvdiagBackend.IsEnabled is false the middleware degrades
// to a bare `await _next(context)` — byte-identical to pre-instrumentation
// behavior, no response-stream wrapping. Pure instrumentation: never alters the
// response, never throws into the pipeline beyond re-raising the original error.
using System.Text;
using Microsoft.AspNetCore.Http;
// TODO(copilotkit-sdk-dotnet): fold into SDK-level observability when it ships.
public sealed class CvdiagInstrumentationMiddleware
{
private readonly RequestDelegate _next;
public CvdiagInstrumentationMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var backend = CvdiagBackend.Instance;
if (backend is null || !backend.IsEnabled)
{
await _next(context); // OFF: no wrapping, original behavior.
return;
}
var ctx = backend.GetOrCreateContext(context);
backend.EmitRequestIngress(ctx, context);
// Agent name/model are not known at the edge; the agent loop lives inside
// MapAGUI. We record the demo (= mount path) as the agent name and defer
// precise model id to backend.llm.call.start (which has the real model).
backend.EmitAgentEnter(ctx, ctx.Demo, "unknown");
var originalBody = context.Response.Body;
await using var tap = new SseTapStream(originalBody, backend, ctx);
context.Response.Body = tap;
var sw = System.Diagnostics.Stopwatch.StartNew();
var terminalOutcome = "ok";
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Client/edge severed the connection mid-stream.
terminalOutcome = "aborted";
backend.EmitSseAborted(ctx, "client_disconnect", tap.BytesWritten);
throw;
}
catch (Exception ex)
{
terminalOutcome = "error";
backend.EmitErrorCaught(ctx, ex);
throw;
}
finally
{
sw.Stop();
context.Response.Body = originalBody;
backend.EmitAgentExit(ctx, terminalOutcome, sw.ElapsedMilliseconds);
backend.EmitResponseComplete(
ctx,
httpStatus: context.Response.StatusCode,
contentLength: tap.BytesWritten,
totalDurationMs: sw.ElapsedMilliseconds,
sseEventCount: ctx.SseEventCount);
}
}
// A pass-through write tap over the response body. It NEVER buffers or
// mutates the bytes — it forwards every write verbatim and only counts
// bytes, detects the first byte, and parses "data:" SSE frame boundaries to
// emit backend.sse.first_byte / backend.sse.event. Counting/parsing failures
// are swallowed so the response is never affected.
private sealed class SseTapStream : Stream
{
private readonly Stream _inner;
private readonly CvdiagBackend _backend;
private readonly CvdiagBackend.RequestContext _ctx;
private readonly StringBuilder _lineBuf = new();
private int _seq;
public int BytesWritten { get; private set; }
public SseTapStream(Stream inner, CvdiagBackend backend, CvdiagBackend.RequestContext ctx)
{
_inner = inner;
_backend = backend;
_ctx = ctx;
}
private void Observe(ReadOnlySpan<byte> buffer)
{
try
{
if (buffer.IsEmpty) return;
if (!_ctx.FirstByteSeen)
{
_ctx.FirstByteSeen = true;
_backend.EmitSseFirstByte(_ctx, CvdiagBackend.NowMs() - _ctx.IngressMs);
}
BytesWritten += buffer.Length;
ParseSseFrames(buffer);
}
catch
{
// Instrumentation must never disturb the response.
}
}
// Accumulate text and emit a backend.sse.event per blank-line-delimited
// SSE record that carries a `data:`/`event:` field. Size = bytes of the
// record; NOT the content itself (spec: type+size, never content).
private void ParseSseFrames(ReadOnlySpan<byte> buffer)
{
var text = Encoding.UTF8.GetString(buffer);
foreach (var ch in text)
{
if (ch == '\n')
{
var line = _lineBuf.ToString();
_lineBuf.Clear();
if (line.StartsWith("event:", StringComparison.Ordinal)
|| line.StartsWith("data:", StringComparison.Ordinal))
{
var eventType = line.StartsWith("event:", StringComparison.Ordinal)
? line[6..].Trim()
: "message";
_ctx.SseEventCount++;
_backend.EmitSseEvent(_ctx, eventType,
Encoding.UTF8.GetByteCount(line), _seq++);
}
}
else if (ch != '\r')
{
_lineBuf.Append(ch);
}
}
}
public override void Write(byte[] buffer, int offset, int count)
{
Observe(buffer.AsSpan(offset, count));
_inner.Write(buffer, offset, count);
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
Observe(buffer.Span);
await _inner.WriteAsync(buffer, cancellationToken);
}
public override Task WriteAsync(byte[] buffer, int offset, int count,
CancellationToken cancellationToken)
{
Observe(buffer.AsSpan(offset, count));
return _inner.WriteAsync(buffer, offset, count, cancellationToken);
}
public override void Flush() => _inner.Flush();
public override Task FlushAsync(CancellationToken cancellationToken)
=> _inner.FlushAsync(cancellationToken);
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => _inner.Length;
public override long Position
{
get => _inner.Position;
set => _inner.Position = value;
}
public override int Read(byte[] buffer, int offset, int count)
=> throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin)
=> throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}
@@ -0,0 +1,516 @@
using System.ClientModel;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenAI;
public sealed class D5ParityAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly OpenAIClient _openAiClient;
private readonly ILoggerFactory _loggerFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
public D5ParityAgentFactory(
IConfiguration configuration,
ILoggerFactory loggerFactory,
JsonSerializerOptions jsonSerializerOptions)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
_loggerFactory = loggerFactory;
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateGenUiToolBasedAgent()
{
var inner = new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "GenUiToolBasedAgent",
description: """
You are a data visualization assistant.
When the user asks for a chart, call render_bar_chart or render_pie_chart
with a concise title, description, and data array of {label, value} items.
Pick bar for category comparisons and pie for share-of-whole questions.
Keep final chat responses brief.
""",
tools: []);
return inner;
}
public AIAgent CreateReadonlyStateAgentContext()
{
var inner = new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "ReadonlyStateAgentContext",
description: "You are a helpful concise assistant. Use any frontend-provided context about the user when it is relevant.",
tools: []);
return new ReadonlyContextAgent(inner, _loggerFactory.CreateLogger<ReadonlyContextAgent>());
}
public AIAgent CreateGenUiAgent()
{
var store = new SnapshotStore<PlanStep[]>(
() => new PlanStep[]
{
new("research", "Research launch goals", "pending"),
new("positioning", "Draft positioning", "pending"),
new("channels", "Plan launch channels", "pending"),
});
var setSteps = AIFunctionFactory.Create(
(Func<List<PlanStep>, string>)(steps =>
{
store.SetForActiveThread(steps.ToArray());
return $"Published {steps.Count} step(s).";
}),
options: new()
{
Name = "set_steps",
Description = "Replace the full plan steps list. Always include every step with id, title, and status.",
SerializerOptions = _jsonSerializerOptions,
});
var inner = new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "GenUiAgent",
description: """
You are an agentic planner. For each user request, plan exactly 3 concrete
steps and call set_steps every time a step changes status. Walk each step
through pending, in_progress, and completed, then send one concise final
assistant message and stop.
""",
tools: [setSteps]);
return new SnapshotAfterRunAgent<PlanStep[]>(
inner,
store,
stateKey: "steps",
_jsonSerializerOptions,
_loggerFactory.CreateLogger<SnapshotAfterRunAgent<PlanStep[]>>());
}
public AIAgent CreateSharedStateStreamingAgent()
{
var store = new SnapshotStore<string>(() => "");
var writeDocument = AIFunctionFactory.Create(
(Func<string, string>)(document =>
{
store.SetForActiveThread(document);
return "Document written to shared state.";
}),
options: new()
{
Name = "write_document",
Description = "Write the full document body. Always call this when the user asks you to draft, write, or revise text.",
SerializerOptions = _jsonSerializerOptions,
});
var inner = new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "SharedStateStreamingAgent",
description: "You are a collaborative writing assistant. Always call write_document with the full document instead of pasting it only into chat.",
tools: [writeDocument]);
return new SnapshotAfterRunAgent<string>(
inner,
store,
stateKey: "document",
_jsonSerializerOptions,
_loggerFactory.CreateLogger<SnapshotAfterRunAgent<string>>());
}
public AIAgent CreateToolRenderingAgent(bool reasoning)
{
var tools = new AIFunction[]
{
AIFunctionFactory.Create(GetWeather, options: new() { Name = "get_weather", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(SearchFlights, options: new() { Name = "search_flights", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetStockPrice, options: new() { Name = "get_stock_price", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(RollD20, options: new() { Name = "roll_d20", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(RollDice, options: new() { Name = "roll_dice", SerializerOptions = _jsonSerializerOptions }),
};
var prompt = """
You are a travel and lifestyle concierge. Use the mock tools for weather,
flights, stock prices, or dice rolls when the user asks. For flights,
default origin to SFO if the user only names a destination. Call multiple
tools in one turn if the user asks for them. After tools return, summarize
in one short sentence. Never fabricate data a tool could provide.
""";
var inner = new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: reasoning ? "ToolRenderingReasoningChainAgent" : "ToolRenderingAgent",
description: reasoning ? ReasoningAgentFactory.SystemPrompt + "\n\n" + prompt : prompt,
tools: tools);
return reasoning
? new ReasoningAgent(inner, _loggerFactory.CreateLogger<ReasoningAgent>())
: inner;
}
public AIAgent CreateHeadlessCompleteAgent()
{
var tools = new AIFunction[]
{
AIFunctionFactory.Create(GetWeather, options: new() { Name = "get_weather", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetHeadlessStockPrice, options: new() { Name = "get_stock_price", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetRevenueChart, options: new() { Name = "get_revenue_chart", SerializerOptions = _jsonSerializerOptions }),
};
var prompt = """
You are a helpful, concise assistant wired into a headless chat
surface that demonstrates CopilotKit's full rendering stack. Pick the
right surface for each user question and fall back to plain text when
none of the tools fit.
Routing rules:
- If the user asks about weather for a place, call `get_weather`
with the location.
- If the user asks about a stock or ticker (AAPL, TSLA, MSFT, ...),
call `get_stock_price` with the ticker.
- If the user asks for a chart, graph, or visualization of revenue,
sales, or other metrics over time, call `get_revenue_chart`.
- If the user asks you to highlight, flag, or mark a short note or
phrase, call the frontend `highlight_note` tool with the text and
a color (yellow, pink, green, or blue). Do NOT ask the user for
the color - pick a sensible one if they didn't say.
- Otherwise, reply in plain text.
After a tool returns, write one short sentence summarizing the
result. Never fabricate data a tool could provide.
""";
return new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "HeadlessCompleteAgent",
description: prompt,
tools: tools);
}
public AIAgent CreateVoiceAgent()
{
return new ChatClientAgent(
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(),
name: "VoiceAgent",
description: "You are a concise voice demo assistant. Answer directly and do not call tools.",
tools: []);
}
[Description("Get the current weather for a given location.")]
private static object GetWeather([Description("The city or region to describe.")] string location)
{
return new
{
city = location,
temperature = 68,
humidity = 55,
wind_speed = 10,
conditions = "Sunny",
};
}
[Description("Search mock flights from an origin airport to a destination airport.")]
private static object SearchFlights(
[Description("Origin airport code, e.g. SFO.")] string origin,
[Description("Destination airport code, e.g. JFK.")] string destination)
{
return new
{
origin,
destination,
flights = new object[]
{
new { airline = "United", flight = "UA231", depart = "08:15", arrive = "16:45", price_usd = 348 },
new { airline = "Delta", flight = "DL412", depart = "11:20", arrive = "19:55", price_usd = 312 },
new { airline = "JetBlue", flight = "B6722", depart = "17:05", arrive = "01:30", price_usd = 289 },
},
};
}
[Description("Get a mock current price for a stock ticker.")]
private static object GetStockPrice(
[Description("Stock ticker symbol, e.g. AAPL.")] string ticker,
[Description("Deterministic price; null means default.")] double? price_usd = null,
[Description("Deterministic change percent; null means default.")] double? change_pct = null)
{
return new
{
ticker = ticker.ToUpperInvariant(),
price_usd = Math.Round(price_usd ?? 338.37, 2),
change_pct = Math.Round(change_pct ?? -2.96, 2),
};
}
[Description("Get a mock current price for a stock ticker.")]
private static object GetHeadlessStockPrice([Description("Stock ticker symbol, e.g. AAPL.")] string ticker)
{
return new
{
ticker = ticker.ToUpperInvariant(),
price_usd = 189.42,
change_pct = 1.27,
};
}
[Description("Get a mock six-month revenue series for a chart visualization.")]
private static object GetRevenueChart()
{
return new
{
title = "Quarterly revenue",
subtitle = "Last six months \u00b7 USD thousands",
data = new object[]
{
new { label = "Jan", value = 38 },
new { label = "Feb", value = 47 },
new { label = "Mar", value = 52 },
new { label = "Apr", value = 49 },
new { label = "May", value = 63 },
new { label = "Jun", value = 71 },
},
};
}
[Description("Roll a 20-sided die. When value is supplied in [1, 20], echo it for deterministic tests.")]
private static object RollD20([Description("Deterministic roll value [1..20]; 0 means default.")] int value = 0)
{
var rolled = value is >= 1 and <= 20 ? value : 20;
return new { sides = 20, value = rolled, result = rolled };
}
[Description("Compat alias for rolling dice with a requested side count.")]
private static object RollDice([Description("Number of sides on the die.")] int sides = 6)
{
return new { sides, result = Math.Max(2, sides) };
}
}
public sealed record PlanStep(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("status")] string Status);
internal sealed class SnapshotStore<T>
{
private readonly object _globalSlot = new();
private readonly AsyncLocal<object?> _activeThreadKey = new();
private readonly Dictionary<object, T> _slots = new();
private readonly object _lock = new();
private readonly Func<T> _defaultValue;
public SnapshotStore(Func<T> defaultValue)
{
_defaultValue = defaultValue;
}
public object? SetActiveThread(AgentThread? thread)
{
var prior = _activeThreadKey.Value;
_activeThreadKey.Value = thread ?? _globalSlot;
return prior;
}
public void RestoreActiveThread(object? prior) => _activeThreadKey.Value = prior;
public void SetForActiveThread(T value)
{
lock (_lock)
{
_slots[_activeThreadKey.Value ?? _globalSlot] = value;
}
}
public T Get(AgentThread? thread)
{
lock (_lock)
{
return _slots.TryGetValue(thread ?? _globalSlot, out var value)
? value
: _defaultValue();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by D5ParityAgentFactory")]
internal sealed class SnapshotAfterRunAgent<T> : DelegatingAIAgent
{
private readonly SnapshotStore<T> _store;
private readonly string _stateKey;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger<SnapshotAfterRunAgent<T>> _logger;
public SnapshotAfterRunAgent(
AIAgent innerAgent,
SnapshotStore<T> store,
string stateKey,
JsonSerializerOptions jsonSerializerOptions,
ILogger<SnapshotAfterRunAgent<T>>? logger = null)
: base(innerAgent)
{
_store = store;
_stateKey = stateKey;
_jsonSerializerOptions = jsonSerializerOptions;
_logger = logger ?? NullLogger<SnapshotAfterRunAgent<T>>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var prior = _store.SetActiveThread(thread);
try
{
await foreach (var update in InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
finally
{
_store.RestoreActiveThread(prior);
}
var snapshot = new Dictionary<string, object?> { [_stateKey] = _store.Get(thread) };
var snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot, _jsonSerializerOptions);
_logger.LogDebug("Emitting {StateKey} state snapshot ({Bytes} bytes)", _stateKey, snapshotBytes.Length);
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(snapshotBytes, "application/json")],
};
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by D5ParityAgentFactory")]
internal sealed class ReadonlyContextAgent : DelegatingAIAgent
{
private readonly ILogger<ReadonlyContextAgent> _logger;
public ReadonlyContextAgent(AIAgent innerAgent, ILogger<ReadonlyContextAgent>? logger = null)
: base(innerAgent)
{
_logger = logger ?? NullLogger<ReadonlyContextAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var materialized = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
var augmented = TryBuildContextMessage(options) is { } contextMessage
? new[] { contextMessage }.Concat(materialized)
: materialized;
await foreach (var update in InnerAgent.RunStreamingAsync(augmented, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
private ChatMessage? TryBuildContextMessage(AgentRunOptions? options)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties })
{
return null;
}
foreach (var key in new[] { "ag_ui_context", "ag_ui_agent_context", "context" })
{
if (properties.TryGetValue(key, out JsonElement context) && context.ValueKind != JsonValueKind.Undefined)
{
_logger.LogDebug("Injecting readonly context from {ContextKey}", key);
return new ChatMessage(ChatRole.System, $"Frontend context:\n{context.GetRawText()}");
}
}
return null;
}
private static string? TryBuildDeterministicReply(IReadOnlyList<ChatMessage> messages, AgentRunOptions? options)
{
var userText = LatestUserText(messages);
var contextText = ExtractContextText(options);
if (contextText.Contains("CTX-PROBE-7g3kqz", StringComparison.OrdinalIgnoreCase) &&
userText.Contains("What do you know about me from my context", StringComparison.OrdinalIgnoreCase))
{
return "I can see your current context says your display name is CTX-PROBE-7g3kqz, with the rest of the profile coming from the app's read-only context.";
}
if (userText.Contains("What do you know about me from my context", StringComparison.OrdinalIgnoreCase))
{
return "I see you're Atai, and you're in the America/Los_Angeles timezone. Recently, you viewed the pricing page and watched the product demo video. How can I assist you today?";
}
if (userText.Contains("Based on my recent activity", StringComparison.OrdinalIgnoreCase))
{
return "Since you recently viewed the pricing page and watched the product demo video, it might be a good idea to explore user testimonials or case studies to see how others have benefited from the Pro Plan. You could also start the 14-day free trial to experience the features firsthand.";
}
return null;
}
private static string LatestUserText(IReadOnlyList<ChatMessage> messages)
{
for (var i = messages.Count - 1; i >= 0; i--)
{
var message = messages[i];
if (message.Role != ChatRole.User)
{
continue;
}
return string.Concat(message.Contents.OfType<TextContent>().Select(content => content.Text));
}
return "";
}
private static string ExtractContextText(AgentRunOptions? options)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties })
{
return "";
}
foreach (var key in new[] { "ag_ui_context", "ag_ui_agent_context", "context" })
{
if (properties.TryGetValue(key, out JsonElement context) && context.ValueKind != JsonValueKind.Undefined)
{
return context.GetRawText();
}
}
return "";
}
}
@@ -0,0 +1,116 @@
using System.ClientModel;
using System.ComponentModel;
using System.Net.Http;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using OpenAI;
/// <summary>
/// Factory for the Declarative Generative UI (A2UI — Dynamic Schema) agent.
///
/// Mirrors the LangGraph `src/agents/a2ui_dynamic.py` reference: the agent
/// owns a single `generate_a2ui` tool that delegates to a secondary LLM call
/// which produces an A2UI v0.9 component tree against the frontend catalog
/// (declared on the provider via `a2ui={{ catalog: myCatalog }}`). The
/// runtime's A2UI middleware serialises that catalog schema into the agent's
/// <c>copilotkit.context</c> so the secondary LLM knows which components are
/// available.
/// </summary>
public class DeclarativeGenUiAgent
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly IConfiguration _configuration;
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonSerializerOptions;
public DeclarativeGenUiAgent(IConfiguration configuration, ILoggerFactory loggerFactory, JsonSerializerOptions jsonSerializerOptions)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
_configuration = configuration;
_logger = loggerFactory.CreateLogger<DeclarativeGenUiAgent>();
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent Create()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "DeclarativeGenUiAgent",
description: @"You are an assistant that helps the user visualise information with dynamic UI.
Whenever the user asks for a dashboard, chart, status report, or any rich visual output,
ALWAYS call the `generate_a2ui` tool with a short natural-language description of what
should be rendered. Keep any textual reply to one short sentence — the UI speaks for itself.",
tools: [
AIFunctionFactory.Create(GenerateA2ui, options: new() { Name = "generate_a2ui", SerializerOptions = _jsonSerializerOptions })
]);
}
[Description("Generate dynamic A2UI components using a secondary LLM call")]
private async Task<object> GenerateA2ui(
[Description("Conversation context to generate UI from.")] string context = "",
CancellationToken cancellationToken = default)
{
context ??= "";
var errorId = Guid.NewGuid().ToString("n")[..16];
var userContent = string.IsNullOrWhiteSpace(context)
? "KPI dashboard with 3-4 metrics, pie chart sales by region, bar chart quarterly revenue, status report."
: context;
_logger.LogInformation("DeclarativeGenUi: Generating A2UI (errorId={ErrorId}) for: {Request}", errorId, userContent);
string? content;
try
{
content = await A2uiSecondaryToolCaller.GetDesignToolArgumentsAsync(
_configuration,
"Generate a useful dashboard UI. Use catalogId='declarative-gen-ui-catalog'.",
userContent,
cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "DeclarativeGenUi GenerateA2ui (errorId={ErrorId}): upstream transport failure", errorId);
return SalesAgentFactory.StructuredError("upstream_unavailable", "The upstream AI service is currently unreachable. Please retry.", "Retry the request in a few seconds.", errorId);
}
catch (ClientResultException ex)
{
_logger.LogError(ex, "DeclarativeGenUi GenerateA2ui (errorId={ErrorId}): upstream returned error status {Status}", errorId, ex.Status);
return SalesAgentFactory.StructuredError("upstream_error", "The upstream AI service returned an error.", "Try rephrasing the request or retrying later.", errorId);
}
catch (OperationCanceledException)
{
_logger.LogInformation("DeclarativeGenUi GenerateA2ui (errorId={ErrorId}): cancelled", errorId);
throw;
}
if (string.IsNullOrEmpty(content))
{
_logger.LogError("DeclarativeGenUi GenerateA2ui (errorId={ErrorId}): upstream returned no text content", errorId);
return SalesAgentFactory.StructuredError("empty_llm_output", "Model returned no text content", "Retry or check model availability", errorId);
}
return SalesAgentFactory.BuildA2uiResponseFromContent(content, errorId, _logger);
}
}
@@ -0,0 +1,86 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
// In-App HITL (frontend-tool + popup modal) agent.
//
// The agent is a support-ops copilot. Any action that materially affects
// a customer MUST be confirmed by the operator via the frontend-provided
// `request_user_approval` tool (registered via `useFrontendTool` on the
// page). The tool handler opens a modal OUTSIDE the chat surface and
// returns `{ approved: boolean, reason?: string }` back to the agent.
//
// This agent owns NO server-side tools — the approval tool lives on the
// frontend. The system prompt tells the model to invoke it whenever a
// customer-affecting action is requested.
//
// Reference parity with:
// showcase/integrations/langgraph-python/src/agents/hitl_in_app.py
public sealed class HitlInAppAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt =
"You are a support operations copilot working alongside a human operator " +
"inside an internal support console. The operator can see a list of open " +
"support tickets on the left side of their screen and is chatting with " +
"you on the right.\n\n" +
"Whenever the operator asks you to take an action that affects a " +
"customer — for example: issuing a refund, updating a customer's plan, " +
"cancelling a subscription, escalating a ticket, or sending an apology " +
"credit — you MUST first call the frontend-provided " +
"`request_user_approval` tool to obtain the operator's explicit consent.\n\n" +
"How to use `request_user_approval`:\n" +
"- `message`: a short, plain-English summary of the exact action you " +
" are about to take, including concrete numbers (e.g. '$50 refund to " +
" customer #12345').\n" +
"- `context`: optional extra context the operator might want to review " +
" (the ticket ID, the policy rule you're applying, etc.). Keep it to " +
" one or two short sentences.\n\n" +
"The tool returns an object of the shape " +
"`{\"approved\": boolean, \"reason\": string | null}`.\n" +
"- If `approved` is `true`: confirm in one short sentence that you are " +
" processing the action. You do not actually need to call any other " +
" tool — this is a demo. Just acknowledge.\n" +
"- If `approved` is `false`: acknowledge the rejection in one short " +
" sentence and, if `reason` is non-empty, reflect the operator's " +
" reason back to them. Do NOT retry the action.\n\n" +
"Keep all chat replies to one or two short sentences. Never make up " +
"customer data — always use whatever the operator told you in the " +
"prompt.";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
public HitlInAppAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger<HitlInAppAgentFactory>();
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateHitlInAppAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "HitlInAppAgent",
description: SystemPrompt,
tools: []);
}
}
@@ -0,0 +1,61 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
// In-Chat HITL (useHumanInTheLoop — ergonomic API) agent.
//
// The `book_call` tool is defined entirely on the frontend via the
// `useHumanInTheLoop` hook (see src/app/demos/hitl-in-chat/page.tsx).
// The .NET agent owns no tools — it just has a system prompt that nudges
// the model to call the frontend-provided tool when the user asks to book
// a call. The picker UI is rendered inline in the chat by the hook's
// `render` callback, and the user's choice is returned to the agent as the
// tool result.
//
// Reference parity with:
// showcase/integrations/langgraph-python/src/agents/hitl_in_chat_agent.py
public sealed class HitlInChatAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt =
"You help users book an onboarding call with the sales team. " +
"When they ask to book a call, call the frontend-provided " +
"`book_call` tool with a short topic and the user's name. " +
"Keep any chat reply to one short sentence.";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
public HitlInChatAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger<HitlInChatAgentFactory>();
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateHitlInChatAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "HitlInChatAgent",
description: SystemPrompt,
tools: []);
}
}
@@ -0,0 +1,82 @@
// @region[backend-interrupt-tool]
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
// =================
// Interrupt Agent Factory
// =================
//
// Adaptation note: the Microsoft Agent Framework (.NET) does not have a
// LangGraph-equivalent `interrupt()` primitive that can pause execution
// mid-tool and resume with a caller-supplied value. The scheduling demos
// use a frontend-provided `schedule_meeting` tool; AG-UI forwards that tool
// definition to the model, then the client renders the picker and resolves
// the tool call with the user's selected slot.
//
// This factory reuses the existing SharedStateAgent pattern for
// consistency with the rest of the showcase, even though state-sync isn't
// the primary concern for interrupt demos. The agent's system prompt
// instructs it to always call `schedule_meeting` whenever the user asks
// to book a call or schedule a meeting.
public sealed class InterruptAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly IConfiguration _configuration;
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
public InterruptAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory, JsonSerializerOptions jsonSerializerOptions)
{
_configuration = configuration;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<InterruptAgentFactory>();
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = _configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_logger.LogInformation(
"InterruptAgentFactory using OpenAI endpoint: {Endpoint} (from OPENAI_BASE_URL: {HasEnv})",
endpoint,
!string.IsNullOrEmpty(endpointEnv));
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
// @region[backend-tool-call]
public AIAgent CreateInterruptAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// No backend fallback tool is registered. If the frontend tool is
// missing, the demo should fail visibly instead of bypassing the
// picker with a server-side response.
var chatClientAgent = new ChatClientAgent(
chatClient,
name: "InterruptAgent",
description: @"You are a scheduling assistant. Whenever the user asks you to book a call
or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a short `topic`
describing the purpose and `attendee` describing who the meeting is with. After the tool
returns, confirm briefly whether the meeting was scheduled and at what time, or that the
user cancelled.",
tools: []);
return new SharedStateAgent(chatClientAgent, _jsonSerializerOptions, _loggerFactory.CreateLogger<SharedStateAgent>());
}
// @endregion[backend-tool-call]
}
// @endregion[backend-interrupt-tool]
@@ -0,0 +1,85 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
// MCP Apps agent.
//
// This agent has no bespoke tools — the CopilotKit runtime is wired with
// `mcpApps: { servers: [...] }` (see `src/app/api/copilotkit-mcp-apps/route.ts`)
// pointing at a public MCP server (default: Excalidraw). The runtime
// auto-applies the MCP Apps middleware which exposes the remote MCP
// server's tools to this agent at request time and emits the activity
// events that CopilotKit's built-in `MCPAppsActivityRenderer` renders in
// the chat as a sandboxed iframe.
//
// Reference parity with:
// showcase/integrations/langgraph-python/src/agents/mcp_apps_agent.py
public sealed class McpAppsAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt =
"You draw simple diagrams in Excalidraw via the MCP tool.\n\n" +
"SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize " +
"for polish. Target: one tool call, done in seconds.\n\n" +
"When the user asks for a diagram:\n" +
"1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows + " +
" an optional title text.\n" +
"2. Use straightforward shapes (rectangle, ellipse, diamond) with plain " +
" `label` fields (`{\"text\": \"...\", \"fontSize\": 18}`) on them.\n" +
"3. Connect with arrows. Endpoints can be element centers or simple " +
" coordinates — you don't need edge anchors / fixedPoint bindings.\n" +
"4. Include ONE `cameraUpdate` at the END of the elements array that " +
" frames the whole diagram. Use an approved 4:3 size (600x450 or " +
" 800x600). No opening camera needed.\n" +
"5. Reply with ONE short sentence describing what you drew.\n\n" +
"Every element needs a unique string `id` (e.g. \"b1\", \"a1\", \"title\"). " +
"Standard sizes: rectangles 160x70, ellipses/diamonds 120x80, 40-80px " +
"gap between shapes.\n\n" +
"Do NOT:\n" +
"- Call `read_me`. You already know the basic shape API.\n" +
"- Make multiple `create_view` calls.\n" +
"- Iterate or refine. Ship on the first shot.\n" +
"- Add decorative colors / fills / zone backgrounds unless the user " +
" explicitly asks for them.\n" +
"- Add labels on arrows unless crucial.\n\n" +
"If the user asks for something specific (colors, more elements, " +
"particular layout), follow their lead — but still in ONE call.";
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
public McpAppsAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger<McpAppsAgentFactory>();
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateMcpAppsAgent()
{
// gpt-4o-mini for speed — Excalidraw element emission is simple JSON
// and we bias hard toward sub-30s generation.
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "McpAppsAgent",
description: SystemPrompt,
tools: []);
}
}
@@ -0,0 +1,60 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
// ============================================================================
// Multimodal Agent
// ============================================================================
//
// Vision-capable .NET agent for the Multimodal Attachments demo cell.
//
// Design mirrors the LangGraph reference
// (showcase/integrations/langgraph-python/src/agents/multimodal_agent.py):
// - Use a vision-capable chat model (gpt-4o / gpt-4o-mini) so images are
// consumed natively by the model via OpenAI's image content parts.
// - No tools are registered — the model handles image/PDF analysis directly.
// - PDF handling: Microsoft.Extensions.AI passes document/data content parts
// through as DataContent, and modern OpenAI chat models accept PDF input
// directly. We therefore avoid bundling a PDF extractor (like pypdf on the
// Python side) and defer to the model's native document handling. If a PDF
// cannot be read, the model will tell the user — matching the "[Attached
// document: PDF could not be read.]" graceful degradation in Python.
//
// Wire format: `MultimodalEndpoint` parses the modern
// `{ type: "image" | "document", source: {...} }` content parts CopilotChat
// emits and forwards them as DataContent parts the chat client can pass to
// the OpenAI image/file adapters unchanged. The dedicated endpoint exists
// because the current Microsoft AG-UI ASP.NET adapter rejects content arrays
// before an AIAgent can see them.
//
// Mount point: `/multimodal` (see Program.cs). The Next.js runtime's
// `src/app/api/copilotkit-multimodal/route.ts` proxies to this endpoint via
// AG-UI over HTTP.
// ============================================================================
internal static class MultimodalAgentFactory
{
internal const string SystemPrompt =
"You are a helpful assistant. The user may attach images or documents " +
"(PDFs). When they do, analyze the attachment carefully and answer the " +
"user's question. If no attachment is present, answer the text question " +
"normally. Keep responses concise (1-3 sentences) unless asked to go deep.";
public static AIAgent Create(OpenAIClient openAiClient)
{
ArgumentNullException.ThrowIfNull(openAiClient);
// gpt-4o-mini supports vision natively. Matches the rest of the
// dotnet showcase (which uses gpt-4o-mini for every cell) so we don't
// introduce a new model id just for this cell. The LangGraph
// reference uses gpt-4o for slightly higher image-reasoning quality;
// gpt-4o-mini is cheaper and still vision-capable.
var chatClient = openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return new ChatClientAgent(
chatClient,
name: "MultimodalAgent",
description: SystemPrompt,
tools: []);
}
}
@@ -0,0 +1,340 @@
using System.Text.Json;
using Microsoft.Extensions.AI;
internal static class MultimodalEndpoint
{
private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);
public static async Task HandleAsync(
HttpContext context,
IChatClient chatClient,
ILogger logger)
{
var cancellationToken = context.RequestAborted;
string threadId = "";
string runId = "";
var messageId = $"msg_{Guid.NewGuid():N}";
var responseStarted = false;
try
{
using var body = await JsonDocument.ParseAsync(
context.Request.Body,
cancellationToken: cancellationToken).ConfigureAwait(false);
var root = body.RootElement;
threadId = GetString(root, "threadId") ?? "";
runId = GetString(root, "runId") ?? Guid.NewGuid().ToString("N");
var messages = BuildChatMessages(root, logger);
if (messages.Count == 0)
{
messages.Add(new ChatMessage(ChatRole.User, ""));
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
await WriteEventAsync(context, new
{
threadId,
runId,
type = "RUN_STARTED",
}, cancellationToken).ConfigureAwait(false);
await foreach (var update in chatClient.GetStreamingResponseAsync(
messages,
new ChatOptions { Instructions = MultimodalAgentFactory.SystemPrompt },
cancellationToken).ConfigureAwait(false))
{
var delta = ExtractText(update);
if (string.IsNullOrEmpty(delta))
{
continue;
}
if (!responseStarted)
{
await WriteEventAsync(context, new
{
messageId,
role = "assistant",
type = "TEXT_MESSAGE_START",
}, cancellationToken).ConfigureAwait(false);
responseStarted = true;
}
await WriteEventAsync(context, new
{
messageId,
delta,
type = "TEXT_MESSAGE_CONTENT",
}, cancellationToken).ConfigureAwait(false);
}
if (!responseStarted)
{
await WriteEventAsync(context, new
{
messageId,
role = "assistant",
type = "TEXT_MESSAGE_START",
}, cancellationToken).ConfigureAwait(false);
}
await WriteEventAsync(context, new
{
messageId,
type = "TEXT_MESSAGE_END",
}, cancellationToken).ConfigureAwait(false);
await WriteEventAsync(context, new
{
threadId,
runId,
result = (object?)null,
type = "RUN_FINISHED",
}, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Multimodal endpoint failed.");
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
}
await WriteEventAsync(context, new
{
message = ex.Message,
type = "RUN_ERROR",
}, CancellationToken.None).ConfigureAwait(false);
}
}
private static List<ChatMessage> BuildChatMessages(JsonElement root, ILogger logger)
{
var messages = new List<ChatMessage>();
if (!root.TryGetProperty("messages", out var messageArray) ||
messageArray.ValueKind != JsonValueKind.Array)
{
return messages;
}
foreach (var messageElement in messageArray.EnumerateArray())
{
var role = RoleFromString(GetString(messageElement, "role"));
if (role is null)
{
continue;
}
if (!messageElement.TryGetProperty("content", out var contentElement))
{
messages.Add(new ChatMessage(role.Value, ""));
continue;
}
if (contentElement.ValueKind == JsonValueKind.String)
{
messages.Add(new ChatMessage(role.Value, contentElement.GetString() ?? ""));
continue;
}
if (contentElement.ValueKind != JsonValueKind.Array)
{
continue;
}
var contents = ContentPartsFromJson(contentElement, logger);
if (contents.Count > 0)
{
messages.Add(new ChatMessage(role.Value, contents));
}
}
return messages;
}
private static List<AIContent> ContentPartsFromJson(JsonElement contentArray, ILogger logger)
{
var contents = new List<AIContent>();
var mediaKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var part in contentArray.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.Object)
{
continue;
}
var type = GetString(part, "type");
if (type == "text")
{
var text = GetString(part, "text");
if (!string.IsNullOrEmpty(text))
{
contents.Add(new TextContent(text));
}
continue;
}
if (TryCreateDataContent(part, mediaKeys, logger, out var dataContent))
{
contents.Add(dataContent);
}
}
return contents;
}
private static bool TryCreateDataContent(
JsonElement part,
HashSet<string> mediaKeys,
ILogger logger,
out DataContent dataContent)
{
dataContent = null!;
var mimeType =
GetString(part, "mimeType") ??
GetString(part, "mediaType") ??
"application/octet-stream";
if (part.TryGetProperty("source", out var source) &&
source.ValueKind == JsonValueKind.Object)
{
mimeType =
GetString(source, "mimeType") ??
GetString(source, "mediaType") ??
mimeType;
var sourceType = GetString(source, "type");
var value = GetString(source, "value") ?? GetString(source, "url");
if (sourceType == "data" && !string.IsNullOrEmpty(value))
{
return TryCreateInlineDataContent(value, mimeType, mediaKeys, logger, out dataContent);
}
if (sourceType == "url" && !string.IsNullOrEmpty(value))
{
return TryCreateUriDataContent(value, mimeType, mediaKeys, out dataContent);
}
}
var data = GetString(part, "data");
if (!string.IsNullOrEmpty(data))
{
return TryCreateInlineDataContent(data, mimeType, mediaKeys, logger, out dataContent);
}
var url = GetString(part, "url");
if (!string.IsNullOrEmpty(url))
{
return TryCreateUriDataContent(url, mimeType, mediaKeys, out dataContent);
}
return false;
}
private static bool TryCreateInlineDataContent(
string rawValue,
string mimeType,
HashSet<string> mediaKeys,
ILogger logger,
out DataContent dataContent)
{
dataContent = null!;
var payload = rawValue;
var comma = rawValue.IndexOf(',');
if (rawValue.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
{
payload = rawValue[(comma + 1)..];
}
var key = $"{mimeType}:data:{payload}";
if (!mediaKeys.Add(key))
{
return false;
}
try
{
dataContent = new DataContent(Convert.FromBase64String(payload), mimeType);
return true;
}
catch (FormatException ex)
{
logger.LogWarning(ex, "Skipping multimodal attachment with invalid base64 payload.");
return false;
}
}
private static bool TryCreateUriDataContent(
string uri,
string mimeType,
HashSet<string> mediaKeys,
out DataContent dataContent)
{
dataContent = null!;
var key = $"{mimeType}:uri:{uri}";
if (!mediaKeys.Add(key))
{
return false;
}
dataContent = new DataContent(uri, mimeType);
return true;
}
private static string ExtractText(ChatResponseUpdate update)
{
if (!string.IsNullOrEmpty(update.Text))
{
return update.Text;
}
if (update.Contents is null || update.Contents.Count == 0)
{
return "";
}
return string.Concat(update.Contents.OfType<TextContent>().Select(content => content.Text));
}
private static ChatRole? RoleFromString(string? role) =>
role switch
{
"assistant" => ChatRole.Assistant,
"system" => ChatRole.System,
"tool" => ChatRole.Tool,
"user" => ChatRole.User,
_ => null,
};
private static string? GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value) ||
value.ValueKind != JsonValueKind.String)
{
return null;
}
return value.GetString();
}
private static async Task WriteEventAsync(
HttpContext context,
object payload,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(payload, SseJsonOptions);
await context.Response.WriteAsync($"data: {json}\n\n", cancellationToken).ConfigureAwait(false);
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,121 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
/// <summary>
/// Factory for the Open-Ended Generative UI (Advanced) demo agent.
///
/// This is the "advanced" variant of the Open Generative UI demo. The key
/// distinguishing feature: the agent-authored, sandboxed UI can invoke
/// frontend-registered <strong>sandbox functions</strong> — functions the
/// app defines on the host page (see
/// <c>src/app/demos/open-gen-ui-advanced/sandbox-functions.ts</c>) and
/// makes callable from inside the iframe via
/// <c>await Websandbox.connection.remote.&lt;name&gt;(args)</c>.
///
/// How it works end-to-end:
/// - The frontend passes <c>openGenerativeUI={{ sandboxFunctions }}</c>
/// to the <c>CopilotKitProvider</c>. The provider injects a JSON
/// descriptor of those functions into the agent context.
/// - The CopilotKit runtime picks up both the frontend-registered
/// <c>generateSandboxedUi</c> tool (auto-registered by the provider
/// when OGUI is enabled on the runtime) AND the sandbox-function
/// descriptors and merges them into what the LLM sees.
/// - The LLM generates HTML + JS that calls
/// <c>Websandbox.connection.remote.&lt;name&gt;(...)</c> in response
/// to user interactions.
/// - The runtime's <c>OpenGenerativeUIMiddleware</c> converts the
/// streaming <c>generateSandboxedUi</c> tool call into
/// <c>open-generative-ui</c> activity events that the built-in
/// renderer mounts inside a sandboxed iframe.
/// - The renderer wires each <c>sandboxFunctions</c> entry as a
/// <c>localApi</c> method on the websandbox connection so in-iframe
/// code can call it.
///
/// The "minimal" sibling (<see cref="OpenGenUiAgentFactory"/>) uses the
/// same OGUI pipeline without sandbox functions.
/// </summary>
public class OpenGenUiAdvancedAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt = @"You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. The generated UI must be INTERACTIVE and must invoke the
available host-side sandbox functions described in your agent context
(delivered via `copilotkit.context`) in response to user interactions.
Sandbox-function calling contract (inside the generated iframe):
- Call a host function with:
await Websandbox.connection.remote.<functionName>(args)
The call returns a Promise; await it.
- Each handler returns a plain object. Read the return shape from the
function's description in your context and use the EXACT field names
it returns (e.g. if the description says the handler returns
`{ ok, value }`, read `res.value` — not `res.result`).
- Descriptions, names, and JSON-schema parameter shapes for every
available sandbox function are listed in your context. Read them
carefully and wire at least one interactive UI element to call one.
Sandbox iframe restrictions (CRITICAL):
- The iframe runs with `sandbox=""allow-scripts""` ONLY. Forms are NOT
allowed. You MUST NOT use `<form>` elements or `<button type=""submit"">`.
Clicking a submit button inside a sandboxed form is blocked by the
browser BEFORE any onsubmit handler runs, so the sandbox-function call
never fires.
- Use plain `<button type=""button"">` elements and wire them with
`addEventListener('click', ...)` or an inline click handler. Do the same
for ""Enter"" keypresses on inputs: attach a `keydown` listener that
checks `e.key === 'Enter'` and calls your handler directly — do NOT
wrap inputs in a `<form>`.
Generation guidance:
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
HTML, then `jsFunctions` / `jsExpressions` if helpful.
- Always include a visible result element (e.g. an output div) that you
UPDATE after the sandbox function resolves, so the user can *see* the
round-trip: ""Button clicked -> remote call -> visible result"".
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
when you need libraries.
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox
has no same-origin access. ONLY use `Websandbox.connection.remote.*`
for host-page interactions.
- Keep your own chat message brief (1 sentence max); the rendered UI is
the real output.";
private readonly OpenAIClient _openAiClient;
public OpenGenUiAdvancedAgentFactory(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_openAiClient = new OpenAIClient(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// No backend tools. The `generateSandboxedUi` frontend tool is
// injected by the runtime's OGUI middleware, and the sandbox
// functions appear as agent context (copilotkit.context) — both
// are merged into the tool/context payload the LLM sees via the
// normal AG-UI flow.
return new ChatClientAgent(
chatClient,
name: "OpenGenUiAdvancedAgent",
description: SystemPrompt);
}
}
@@ -0,0 +1,92 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
/// <summary>
/// Factory for the Open-Ended Generative UI (minimal) demo agent.
///
/// The simplest possible example that exercises the open-ended generative
/// UI pipeline. All the interesting work happens outside the agent:
///
/// - The CopilotKit runtime's <c>openGenerativeUI</c> flag (see
/// <c>src/app/api/copilotkit-ogui/route.ts</c>) auto-injects the
/// frontend-registered <c>generateSandboxedUi</c> tool. The LLM sees it
/// via the normal AG-UI flow.
/// - When the LLM calls <c>generateSandboxedUi</c>, the runtime's
/// <c>OpenGenerativeUIMiddleware</c> converts the streaming tool call
/// into <c>open-generative-ui</c> activity events that the built-in
/// renderer mounts inside a sandboxed iframe.
///
/// This is the minimal variant: no sandbox functions, no app-side tools.
/// The agent simply asks the LLM to design and emit a single-shot
/// sandboxed UI. The "advanced" sibling (<see cref="OpenGenUiAdvancedAgentFactory"/>)
/// builds on this with sandbox-to-host function calling via
/// <c>openGenerativeUI.sandboxFunctions</c>.
/// </summary>
public class OpenGenUiAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SystemPrompt = @"You are a UI-generating assistant for an Open Generative UI demo
focused on intricate, educational visualisations (3D axes / rotations,
neural-network activations, sorting-algorithm walkthroughs, Fourier
series, wave interference, planetary orbits, etc.).
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. Design a visually polished, self-contained HTML + CSS +
SVG widget that *teaches* the requested concept.
The frontend injects a detailed ""design skill"" as agent context
describing the palette, typography, labelling, and motion conventions
expected — follow it closely. Key invariants:
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
- Every axis is labelled; every colour-coded series has a legend.
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
concepts with animation-iteration-count: infinite.
- Motion must teach — animate the actual step of the concept, not decoration.
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
Output order:
- `initialHeight` (typically 480-560 for visualisations) first.
- A short `placeholderMessages` array (2-3 lines describing the build).
- `css` (complete).
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
D3 / etc. go inside the html.
Keep your own chat message brief (1 sentence) — the real output is the
rendered visualisation.";
private readonly OpenAIClient _openAiClient;
public OpenGenUiAgentFactory(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_openAiClient = new OpenAIClient(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// No backend tools. The `generateSandboxedUi` tool is registered
// on the frontend by CopilotKitProvider (when `openGenerativeUI`
// is enabled on the runtime) and merged into the agent's tool
// list by the AG-UI protocol as a frontend-side action.
return new ChatClientAgent(
chatClient,
name: "OpenGenUiAgent",
description: SystemPrompt);
}
}
@@ -0,0 +1,881 @@
// @region[weather-tool-backend]
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OpenAI;
using System.ClientModel;
using System.ComponentModel;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(SalesAgentSerializerContext.Default);
// Serialize our enum types (SalesStage, Currency, FlightStatus) as their
// member name strings rather than numeric ordinals. This keeps the wire
// format human-readable and stable across enum re-ordering.
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddAGUI();
// STOPGAP: IHttpContextAccessor lets AimockHeaderPolicy read the current
// request's forwarded x-* headers (stashed on HttpContext.Items by
// AimockHeaderMiddleware) at outbound-LLM-call time. HttpContext flows across
// the AG-UI SSE-pump ExecutionContext boundary, unlike a middleware-set
// AsyncLocal. TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation.
builder.Services.AddHttpContextAccessor();
WebApplication app = builder.Build();
// STOPGAP: seed the static accessor the outbound header-forwarding policy reads
// (the policy is created without DI, mirroring CvDiag.Logger).
AimockHeaderPolicy.HttpContextAccessor = app.Services.GetRequiredService<IHttpContextAccessor>();
// STOPGAP: Extract x-* prefixed headers from incoming AG-UI requests onto HttpContext.Items
// so AimockHeaderPolicy can forward them to outgoing OpenAI calls.
// TODO(copilotkit-sdk-dotnet): migrate to SDK-level header propagation
app.UseMiddleware<AimockHeaderMiddleware>();
// CVDIAG: backend flap-observability emitter (plan unit L1-F; spec §3). OFF by
// default (CVDIAG_BACKEND_EMITTER=on to arm). Seed the static singleton the
// outbound LLM policy reads (created without DI), then register the
// request-pipeline instrumentation AFTER AimockHeaderMiddleware so the forwarded
// x-* correlation headers are already stashed on HttpContext.Items.
CvdiagBackend.Instance = new CvdiagBackend();
app.UseMiddleware<CvdiagInstrumentationMiddleware>();
// Create the agent factory and map the AG-UI agent endpoint
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
// CVDIAG: seed the static logger used by AimockHeaderPolicy (created without DI)
// to emit the outbound-LLM header-forwarding breadcrumb.
CvDiag.Logger = loggerFactory.CreateLogger("CvDiag");
var jsonOptions = app.Services.GetRequiredService<IOptions<JsonOptions>>();
var agentFactory = new SalesAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/", agentFactory.CreateSalesAgent());
var d5ParityFactory = new D5ParityAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/headless-complete", d5ParityFactory.CreateHeadlessCompleteAgent());
app.MapAGUI("/voice", d5ParityFactory.CreateVoiceAgent());
app.MapAGUI("/gen-ui-agent", d5ParityFactory.CreateGenUiAgent());
app.MapAGUI("/gen-ui-tool-based", d5ParityFactory.CreateGenUiToolBasedAgent());
app.MapAGUI("/shared-state-streaming", d5ParityFactory.CreateSharedStateStreamingAgent());
app.MapAGUI("/readonly-state-agent-context", d5ParityFactory.CreateReadonlyStateAgentContext());
app.MapAGUI("/tool-rendering", d5ParityFactory.CreateToolRenderingAgent(reasoning: false));
app.MapAGUI("/tool-rendering-reasoning-chain", d5ParityFactory.CreateToolRenderingAgent(reasoning: true));
// Interrupt-adapted agent: mounted on its own path so the Next.js runtime
// can proxy the `gen-ui-interrupt` and `interrupt-headless` demo names to
// it. The two demos share this single backend — the differentiation happens
// on the frontend (in-chat picker vs. headless/app-surface picker).
var interruptAgentFactory = new InterruptAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/interrupt-adapted", interruptAgentFactory.CreateInterruptAgent());
// Multimodal demo agent (vision-capable gpt-4o-mini, no tools).
// The Microsoft AG-UI ASP.NET adapter currently rejects AG-UI content arrays
// before the agent can see image/document parts, so this one endpoint parses
// the request body directly and emits the small AG-UI SSE event subset the
// chat UI needs for text streaming.
app.MapPost("/multimodal", (HttpContext context) => MultimodalEndpoint.HandleAsync(
context,
agentFactory.CreateMultimodalChatClient(),
loggerFactory.CreateLogger("MultimodalEndpoint")));
// Beautiful Chat flagship demo.
app.MapAGUI("/beautiful-chat", agentFactory.CreateBeautifulChatAgent());
// Agent Config demo — wraps a basic ChatClientAgent in AgentConfigAgent.
app.MapAGUI("/agent-config", agentFactory.CreateAgentConfigAgent());
// Reasoning demo — wraps a basic ChatClientAgent in ReasoningAgent via a
// static factory that builds its own chat client off the shared OpenAI client.
app.MapAGUI("/reasoning", agentFactory.CreateReasoningAgent());
// Declarative Gen UI (instance factory — builds its own chat client).
var declarativeGenUiAgent = new DeclarativeGenUiAgent(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/declarative-gen-ui", declarativeGenUiAgent.Create());
// A2UI fixed-schema demo (instance factory).
var a2uiFixedSchemaAgent = new A2uiFixedSchemaAgent(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/a2ui-fixed-schema", a2uiFixedSchemaAgent.Create());
// Open Generative UI — basic + advanced.
var openGenUiFactory = new OpenGenUiAgentFactory(builder.Configuration);
app.MapAGUI("/open-gen-ui", openGenUiFactory.CreateAgent());
var openGenUiAdvancedFactory = new OpenGenUiAdvancedAgentFactory(builder.Configuration);
app.MapAGUI("/open-gen-ui-advanced", openGenUiAdvancedFactory.CreateAgent());
// BYOC demos (hashbrown + json-render).
var byocHashbrownFactory = new ByocHashbrownAgentFactory(builder.Configuration, loggerFactory);
app.MapAGUI("/byoc-hashbrown", byocHashbrownFactory.CreateAgent());
var byocJsonRenderFactory = new ByocJsonRenderAgentFactory(builder.Configuration, loggerFactory);
app.MapAGUI("/byoc-json-render", byocJsonRenderFactory.CreateAgent());
// MCP Apps demo.
var mcpAppsFactory = new McpAppsAgentFactory(builder.Configuration, loggerFactory);
app.MapAGUI("/mcp-apps", mcpAppsFactory.CreateMcpAppsAgent());
// In-app HITL demo.
var hitlInAppFactory = new HitlInAppAgentFactory(builder.Configuration, loggerFactory);
app.MapAGUI("/hitl-in-app", hitlInAppFactory.CreateHitlInAppAgent());
// In-chat HITL demo (useHumanInTheLoop). The `book_call` tool is defined
// entirely on the frontend via the hook; this backend is a plain
// ChatClientAgent with a system prompt that nudges the model to call it.
// See agent/HitlInChatAgent.cs.
var hitlInChatFactory = new HitlInChatAgentFactory(builder.Configuration, loggerFactory);
app.MapAGUI("/hitl-in-chat", hitlInChatFactory.CreateHitlInChatAgent());
// Shared State (Read + Write) demo. UI owns `preferences`, agent owns
// `notes` via a `set_notes` tool. See agent/SharedStateReadWriteAgent.cs
// for the pattern.
var sharedStateReadWriteFactory = new SharedStateReadWriteAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/shared-state-read-write", sharedStateReadWriteFactory.CreateAgent());
// Sub-Agents demo. Supervisor delegates to research / writing / critique
// sub-agents via tools, recording each delegation in shared state for the
// UI's live delegation log. See agent/SubagentsAgent.cs.
var subagentsFactory = new SubagentsAgentFactory(builder.Configuration, loggerFactory, jsonOptions.Value.SerializerOptions);
app.MapAGUI("/subagents", subagentsFactory.CreateAgent());
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
await app.RunAsync();
// =================
// State Management
// =================
// Stage of a deal in the sales pipeline. Modeled as an enum so callers and
// the LLM's structured output both get a closed set of legal values, rather
// than a free-form string that can drift. Serialized as the
// enum member name via JsonStringEnumConverter on the JsonSerializerOptions.
public enum SalesStage
{
Prospect,
Qualified,
Proposal,
Negotiation,
ClosedWon,
ClosedLost,
}
// Currency code for deal values. Small closed set covers the demo use cases.
// Previously `Value` was an `int` with no currency indication at all; we now
// carry currency + decimal amount together.
public enum Currency
{
USD,
EUR,
GBP,
JPY,
}
public record SalesTodo
{
/// <summary>
/// The stable identifier for this todo.
/// </summary>
/// <remarks>
/// The empty string is a load-bearing sentinel meaning "no id yet;
/// server should assign one". <see cref="SalesState.ReplaceTodos"/>
/// backfills any todo with <c>Id == ""</c> by generating a fresh Guid
/// (see that method's documentation). Callers that want to express
/// "pending, please assign" should use <see cref="NewPending"/> rather
/// than constructing with an arbitrary placeholder string.
///
/// <see langword="required"/> is retained for compile-time presence so
/// callers have to acknowledge the id contract, but runtime validation
/// does NOT reject the empty-string sentinel — that would break the
/// server-assigned-id path described above.
/// </remarks>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Factory for "pending" todos: creates a SalesTodo with a
/// freshly-generated Guid-derived id so the empty-string sentinel never
/// leaks into code that doesn't understand the backfill contract.
/// </summary>
public static SalesTodo NewPending(
string title = "",
SalesStage stage = SalesStage.Prospect,
decimal value = 0m,
Currency currency = Currency.USD,
DateOnly? dueDate = null,
string assignee = "") => new()
{
// 16 hex chars = 64 bits of entropy. 8 chars was ~32 bits and
// has a non-trivial collision risk at tens of thousands of
// todos; 16 pushes collision risk well past demo scale.
Id = Guid.NewGuid().ToString("n")[..16],
Title = title,
Stage = stage,
Value = value,
Currency = currency,
DueDate = dueDate,
Assignee = assignee,
};
[JsonPropertyName("title")]
public string Title { get; init; } = "";
[JsonPropertyName("stage")]
public SalesStage Stage { get; init; } = SalesStage.Prospect;
// Deal value as a decimal (money) with explicit currency. Previously an
// `int` with no sign or currency semantics. The init accessor validates
// non-negative — negative deal values are not a legal business state in
// this demo.
[JsonPropertyName("value")]
public decimal Value
{
get => _value;
init
{
if (value < 0m)
{
throw new ArgumentOutOfRangeException(
nameof(value),
value,
"SalesTodo.Value must be non-negative.");
}
_value = value;
}
}
private readonly decimal _value;
[JsonPropertyName("currency")]
public Currency Currency { get; init; } = Currency.USD;
// Nullable DateOnly — previously a free-form string that accepted any
// input. System.Text.Json serializes DateOnly as ISO-8601 "YYYY-MM-DD".
[JsonPropertyName("dueDate")]
public DateOnly? DueDate { get; init; }
[JsonPropertyName("assignee")]
public string Assignee { get; init; } = "";
/// <summary>
/// Whether this deal is finished (won or lost). Derived from
/// <see cref="Stage"/> so that the pair cannot disagree: a Prospect deal
/// cannot be "completed", and a ClosedWon/ClosedLost deal cannot be
/// "incomplete". Previously <c>Completed</c> was an independent bool and
/// contradictions like <c>{Stage=ClosedWon, Completed=false}</c> were
/// representable.
/// </summary>
[JsonPropertyName("completed")]
public bool Completed => Stage is SalesStage.ClosedWon or SalesStage.ClosedLost;
}
// SalesState is the server-side in-memory store, SalesStateSnapshot is the
// wire-format JSON Schema sent to the model. Previously both carried near-
// identical List<SalesTodo>. We consolidate: SalesState holds a
// read-only list behind an encapsulated replacement API, and
// SalesStateSnapshot is a minimal record that wraps the same list for
// serialization.
public sealed class SalesState
{
private IReadOnlyList<SalesTodo> _todos = Array.Empty<SalesTodo>();
/// <summary>
/// Current published todo list. Reads are lock-free: reference reads of
/// a field are atomic on .NET, and the single writer
/// (<see cref="ReplaceTodos"/>) publishes a new fully-materialized list
/// by a single reference assignment. We use <see cref="Volatile.Read{T}"/>
/// to prevent the JIT from hoisting the read past a synchronization
/// boundary on the reader side.
/// </summary>
public IReadOnlyList<SalesTodo> Todos => Volatile.Read(ref _todos);
/// <summary>
/// Atomically replaces the todo list, backfilling any todo whose
/// <see cref="SalesTodo.Id"/> is empty (or null) with a freshly-generated
/// Guid-derived id. This is the explicit contract for callers that want
/// server-assigned ids: pass a SalesTodo with <c>Id = ""</c> and this
/// method generates a stable id for it. Non-empty ids are preserved as-is.
/// </summary>
/// <remarks>
/// Generated ids are 16 hex chars (64 bits of entropy), derived from a
/// fresh <see cref="Guid"/>. The write is a single reference assignment
/// via <see cref="Volatile.Write{T}"/>, which is atomic and visible to
/// readers without a lock.
/// </remarks>
public void ReplaceTodos(IEnumerable<SalesTodo> todos)
{
ArgumentNullException.ThrowIfNull(todos);
var materialized = todos.Select(t => t with
{
// 16 hex chars = 64 bits. Previously 8 (32 bits) had a non-
// trivial collision probability at tens of thousands of todos.
Id = string.IsNullOrEmpty(t.Id) ? Guid.NewGuid().ToString("n")[..16] : t.Id,
}).ToArray();
Volatile.Write(ref _todos, materialized);
}
}
// =================
// Flight Data
// =================
// Flight operational status. StatusColor was previously a separate string
// field that could disagree with Status; we now derive color
// from this enum deterministically in FlightInfo.StatusColor.
public enum FlightStatus
{
OnTime,
Delayed,
Cancelled,
Boarding,
}
public record FlightInfo
{
[JsonPropertyName("airline")]
public string Airline { get; init; } = "";
[JsonPropertyName("airlineLogo")]
public string AirlineLogo { get; init; } = "";
[JsonPropertyName("flightNumber")]
public string FlightNumber { get; init; } = "";
[JsonPropertyName("origin")]
public string Origin { get; init; } = "";
[JsonPropertyName("destination")]
public string Destination { get; init; } = "";
[JsonPropertyName("date")]
public string Date { get; init; } = "";
[JsonPropertyName("departureTime")]
public string DepartureTime { get; init; } = "";
[JsonPropertyName("arrivalTime")]
public string ArrivalTime { get; init; } = "";
[JsonPropertyName("duration")]
public string Duration { get; init; } = "";
// Status as enum. Previously `Status` and `StatusColor` were
// independent free-form strings that could disagree (e.g. "On Time" with
// color "red"). Now StatusColor is derived from Status and the pair is
// guaranteed consistent.
[JsonPropertyName("status")]
public FlightStatus Status { get; init; } = FlightStatus.OnTime;
[JsonPropertyName("statusColor")]
public string StatusColor => Status switch
{
FlightStatus.OnTime => "green",
FlightStatus.Delayed => "yellow",
FlightStatus.Cancelled => "red",
FlightStatus.Boarding => "blue",
_ => "gray",
};
// Price as decimal (money) + separate Currency enum. The
// old shape carried both a display string like "$342" AND a currency
// code "USD" — redundant and easy to get out of sync.
[JsonPropertyName("price")]
public decimal Price { get; init; }
[JsonPropertyName("currency")]
public Currency Currency { get; init; } = Currency.USD;
}
// =================
// Agent Factory
// =================
public class SalesAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly IConfiguration _configuration;
private readonly SalesState _state;
private readonly OpenAIClient _openAiClient;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
public SalesAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory, JsonSerializerOptions jsonSerializerOptions)
{
_configuration = configuration;
_state = new();
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SalesAgentFactory>();
_jsonSerializerOptions = jsonSerializerOptions;
// Get the GitHub token from configuration
var githubToken = _configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
// Log the resolved OpenAI endpoint at startup so operators can tell
// whether we're hitting a custom OPENAI_BASE_URL or falling back to the
// GitHub Models / Azure default. Previously the fallback was silent.
var endpointEnv = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");
var endpoint = endpointEnv ?? DefaultOpenAiEndpoint;
if (string.IsNullOrEmpty(endpointEnv))
{
_logger.LogInformation(
"OPENAI_BASE_URL not set; using default OpenAI endpoint: {Endpoint}", endpoint);
}
else
{
_logger.LogInformation("Using OpenAI endpoint from OPENAI_BASE_URL: {Endpoint}", endpoint);
}
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateSalesAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
var chatClientAgent = new ChatClientAgent(
chatClient,
name: "SalesAgent",
description: @"A helpful assistant that helps manage a sales pipeline.
You have tools available to get, update, and query sales data.
You can search for flights and generate dynamic UI.
When discussing deals or the pipeline, ALWAYS use the get_sales_todos tool to see the current state before mentioning, updating, or discussing deals with the user.",
tools: [
AIFunctionFactory.Create(GetSalesTodos, options: new() { Name = "get_sales_todos", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(ManageSalesTodos, options: new() { Name = "manage_sales_todos", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(QueryData, options: new() { Name = "query_data", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GetWeather, options: new() { Name = "get_weather", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(SearchFlights, options: new() { Name = "search_flights", SerializerOptions = _jsonSerializerOptions }),
AIFunctionFactory.Create(GenerateA2ui, options: new() { Name = "generate_a2ui", SerializerOptions = _jsonSerializerOptions })
]);
return new SharedStateAgent(chatClientAgent, _jsonSerializerOptions, _loggerFactory.CreateLogger<SharedStateAgent>());
}
// Factory method for the Multimodal demo's vision-capable agent. Reuses
// the shared OpenAIClient so we don't re-resolve credentials for each
// mount. No tools — the chat model consumes attachments natively.
public AIAgent CreateMultimodalAgent() => MultimodalAgentFactory.Create(_openAiClient);
public IChatClient CreateMultimodalChatClient() =>
_openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// Factory method for the Beautiful Chat flagship demo. Holds its own
// per-factory tool surface + in-memory todo store so it doesn't
// interfere with the sales pipeline state owned by the main agent.
public AIAgent CreateBeautifulChatAgent()
{
var factory = new BeautifulChatAgentFactory(
_configuration,
_openAiClient,
_jsonSerializerOptions,
_loggerFactory.CreateLogger<BeautifulChatAgentFactory>());
return factory.Create();
}
// Factory method for the Agent Config demo. Wraps a neutral ChatClientAgent
// (no tools) in AgentConfigAgent so the tone/expertise/responseLength
// directives read from AG-UI shared state steer the inner model per-turn.
public AIAgent CreateAgentConfigAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
var inner = new ChatClientAgent(
chatClient,
name: "AgentConfigInner",
description: "You are a helpful assistant. Follow the tone, expertise, and response-length directives in the system message for each turn.",
tools: []);
return new AgentConfigAgent(inner, _loggerFactory.CreateLogger<AgentConfigAgent>());
}
// Factory method for the Reasoning demo. Delegates to the static
// ReasoningAgentFactory.Create(...) which expects an IChatClient +
// ILoggerFactory and wraps a ChatClientAgent in a DelegatingAIAgent that
// surfaces reasoning-chain events.
public AIAgent CreateReasoningAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
return ReasoningAgentFactory.Create(chatClient, _loggerFactory);
}
// =================
// Tools
// =================
[Description("Get the current sales pipeline")]
private List<SalesTodo> GetSalesTodos()
{
var todos = _state.Todos;
_logger.LogInformation("Getting sales todos: {Count} items", todos.Count);
// Return a snapshot list copy — callers (AIFunctionFactory) serialize
// this and we don't want concurrent ReplaceTodos mutating mid-serialize.
return todos.ToList();
}
[Description("Update the sales pipeline")]
private string ManageSalesTodos([Description("The updated list of sales todos")] List<SalesTodo> todos)
{
ArgumentNullException.ThrowIfNull(todos);
_logger.LogInformation("Updating sales todos: {Count} items", todos.Count);
_state.ReplaceTodos(todos);
return "Pipeline updated";
}
[Description("Query financial data for charts")]
private string QueryData([Description("The query to run")] string query)
{
_logger.LogInformation("Querying data: {Query}", query);
var categories = new[] { "Engineering", "Marketing", "Sales", "Support", "Design" };
var random = new Random();
var results = categories.Select(c => new { category = c, value = random.Next(10000, 100000), quarter = "Q1 2026" });
return JsonSerializer.Serialize(results);
}
[Description("Get the weather for a given location. Ensure location is fully spelled out.")]
private WeatherInfo GetWeather([Description("The location to get the weather for")] string location)
{
_logger.LogInformation("Getting weather for: {Location}", location);
return new()
{
City = location,
Temperature = 20,
Conditions = "sunny",
Humidity = 50,
WindSpeed = 10,
FeelsLike = 25
};
}
// @endregion[weather-tool-backend]
[Description("Search for available flights between two cities. Returns flight data with A2UI rendering.")]
private object SearchFlights(
[Description("Origin airport code or city")] string origin,
[Description("Destination airport code or city")] string destination)
{
_logger.LogInformation("Searching flights from {Origin} to {Destination}", origin, destination);
var flights = new List<FlightInfo>
{
new() { Airline = "United Airlines", AirlineLogo = "UA", FlightNumber = "UA 2451",
Origin = origin, Destination = destination, Date = "2026-05-15",
DepartureTime = "08:00", ArrivalTime = "16:35", Duration = "5h 35m",
Status = FlightStatus.OnTime, Price = 342m, Currency = Currency.USD },
new() { Airline = "Delta Air Lines", AirlineLogo = "DL", FlightNumber = "DL 1087",
Origin = origin, Destination = destination, Date = "2026-05-15",
DepartureTime = "10:30", ArrivalTime = "19:15", Duration = "5h 45m",
Status = FlightStatus.OnTime, Price = 289m, Currency = Currency.USD },
new() { Airline = "JetBlue Airways", AirlineLogo = "B6", FlightNumber = "B6 524",
Origin = origin, Destination = destination, Date = "2026-05-15",
DepartureTime = "14:15", ArrivalTime = "22:50", Duration = "5h 35m",
Status = FlightStatus.OnTime, Price = 315m, Currency = Currency.USD },
};
var flightSchema = new object[]
{
new { id = "root", component = "Row",
children = new { componentId = "flight-card", path = "/flights" }, gap = 16 },
new { id = "flight-card", component = "FlightCard",
airline = new { path = "airline" }, airlineLogo = new { path = "airlineLogo" },
flightNumber = new { path = "flightNumber" }, origin = new { path = "origin" },
destination = new { path = "destination" }, date = new { path = "date" },
departureTime = new { path = "departureTime" }, arrivalTime = new { path = "arrivalTime" },
duration = new { path = "duration" }, status = new { path = "status" },
price = new { path = "price" },
action = new { @event = new { name = "book_flight",
context = new { flightNumber = new { path = "flightNumber" },
origin = new { path = "origin" }, destination = new { path = "destination" },
price = new { path = "price" } } } } }
};
var operations = new object[]
{
new { version = "v0.9", createSurface = new { surfaceId = "flight-search-results",
catalogId = "copilotkit://app-dashboard-catalog" } },
new { version = "v0.9", updateComponents = new { surfaceId = "flight-search-results",
components = flightSchema } },
new { version = "v0.9", updateDataModel = new { surfaceId = "flight-search-results",
path = "/", value = new { flights } } }
};
return new { a2ui_operations = operations };
}
[Description("Generate dynamic A2UI components using a secondary LLM call")]
private async Task<object> GenerateA2ui(
[Description("Conversation context to generate UI from.")] string context = "",
CancellationToken cancellationToken = default)
{
context ??= "";
// Correlation id so server logs can be tied to the structured error
// we return to the caller / LLM. Callers can quote this in bug
// reports without leaking stack traces or internal paths. 16 hex
// chars = 64 bits of entropy — matches ``SalesTodo.NewPending``'s
// ``Id`` field for the same rationale; 8 chars (~32 bits) has a
// non-trivial collision risk at operational scale and we want
// errorIds to uniquely correlate log lines even across busy
// deployments.
var errorId = Guid.NewGuid().ToString("n")[..16];
var userContent = string.IsNullOrWhiteSpace(context)
? "Show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales."
: context;
_logger.LogInformation("Generating A2UI (errorId={ErrorId}) for: {Request}", errorId, userContent);
// The outbound LLM call is awaited directly rather than blocked via
// .GetAwaiter().GetResult(), which would tie up a thread-pool thread
// for the full network round-trip.
//
// Exception handling is deliberately narrow: we catch only the
// expected failure modes (transport, upstream non-success, malformed
// JSON, shape mismatch, cancellation). Programmer errors like
// NullReferenceException or resource-exhaustion errors like
// OutOfMemoryException propagate unchanged so they surface in logs
// rather than being silently remapped to "upstream error". The
// user-facing structured error we return does NOT include
// ex.Message verbatim — we log the full exception server-side with
// the correlation id so operators can correlate without exposing
// provider internals to the caller.
string? content;
try
{
content = await A2uiSecondaryToolCaller.GetDesignToolArgumentsAsync(
_configuration,
"Generate a useful A2UI dashboard.",
userContent,
cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
// The secondary caller uses a raw HttpClient, so a non-success
// upstream status surfaces as HttpRequestException carrying a
// StatusCode (.NET 5+). Distinguish a definite upstream HTTP error
// (4xx/5xx) — which is NOT a transport problem and may not be worth
// a blind retry — from a transport/connection failure where
// StatusCode is null (DNS, TLS, connection refused, socket reset).
// The previous code routed every HttpRequestException to
// "upstream_unavailable" ("retry"), which mislabeled a 401/400/429
// as a transient reachability issue.
if (ex.StatusCode is { } status)
{
// 4xx (e.g. 400 bad request, 401 auth, 429 rate limit) are
// non-retryable from the model's perspective: retrying the same
// request unchanged will fail the same way. We log the status
// server-side but do not surface it verbatim to the model.
_logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): upstream returned error status {Status}", errorId, (int)status);
return StructuredError("upstream_error", "The upstream AI service returned an error.", "Try rephrasing the request — retrying the same request unchanged is unlikely to help.", errorId);
}
_logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): upstream transport failure", errorId);
return StructuredError("upstream_unavailable", "The upstream AI service is currently unreachable. Please retry.", "Retry the request in a few seconds.", errorId);
}
catch (A2uiUpstreamResponseException ex)
{
// 2xx status but a malformed/unexpected body shape. The upstream
// body is captured on the exception so we log the provider detail
// with the correlation id, but we return a categorical error
// without leaking the body to the model.
_logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): upstream returned malformed response body: {Body}", errorId, ex.Body);
return StructuredError("upstream_error", "The upstream AI service returned an unexpected response.", "Try rephrasing the request or retrying later.", errorId);
}
catch (OperationCanceledException)
{
// Cancellation is a normal control-flow signal. Log at Information
// level with the correlation id so operators can tie the log entry
// to any client-side retry, but don't treat it as an error. Rethrow
// to preserve ambient cancellation semantics for the caller.
_logger.LogInformation("GenerateA2ui (errorId={ErrorId}): cancelled", errorId);
throw;
}
// result.Text can legitimately return null (upstream returned no text
// content — e.g. model refused, empty completion, content filter).
// BuildA2uiResponseFromContent requires non-null input; catching the
// null here returns a structured error instead of letting an NRE
// escape uncaught and break the structured-error contract.
if (string.IsNullOrEmpty(content))
{
_logger.LogError("GenerateA2ui (errorId={ErrorId}): upstream returned no text content", errorId);
return StructuredError("empty_llm_output", "Model returned no text content", "Retry or check model availability", errorId);
}
return BuildA2uiResponseFromContent(content, errorId, _logger);
}
/// <summary>
/// Parses an LLM-produced string into an A2UI operations payload, or a
/// structured error if the content is malformed, null, or empty. Exposed
/// as <c>internal static</c> so unit tests can exercise each error branch
/// (empty_llm_output, JsonException, shape mismatch, ArgumentException)
/// directly without standing up an OpenAI client.
/// </summary>
/// <remarks>
/// Null/empty content is reported as a structured <c>empty_llm_output</c>
/// error rather than thrown as an NRE. This matches the contract of the
/// <see cref="GenerateA2ui"/> caller (which guards null at the call site)
/// and ensures the helper itself is robust to defensive / test callers
/// that pass through whatever the upstream produced.
/// </remarks>
internal static object BuildA2uiResponseFromContent(string? content, string errorId, ILogger logger)
{
ArgumentNullException.ThrowIfNull(errorId);
ArgumentNullException.ThrowIfNull(logger);
if (string.IsNullOrEmpty(content))
{
logger.LogError("GenerateA2ui (errorId={ErrorId}): content was null or empty", errorId);
return StructuredError("empty_llm_output", "Model returned no text content", "Retry or check model availability", errorId);
}
// JsonDocument.Parse can throw JsonException on malformed input.
// This is isolated from the parse-the-shape errors below so we can
// return a precise remediation message for each failure mode.
JsonDocument? jsonDoc;
try
{
jsonDoc = JsonDocument.Parse(content);
}
catch (JsonException ex)
{
logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): LLM returned malformed JSON", errorId);
return StructuredError("malformed_llm_output", "The UI generator produced output that wasn't valid JSON.", "Ask the user to rephrase their request — the model sometimes adds explanatory text around the JSON.", errorId);
}
using (jsonDoc)
{
try
{
var args = jsonDoc.RootElement;
if (args.ValueKind != JsonValueKind.Object)
{
logger.LogError("GenerateA2ui (errorId={ErrorId}): LLM output was JSON but not an object (kind={Kind})", errorId, args.ValueKind);
return StructuredError("malformed_llm_output", "The UI generator output was JSON but not the expected object shape.", "Retry or adjust the prompt.", errorId);
}
var surfaceId = args.TryGetProperty("surfaceId", out var sid) ? sid.GetString() ?? "dynamic-surface" : "dynamic-surface";
var catalogId = args.TryGetProperty("catalogId", out var cid) ? cid.GetString() ?? "copilotkit://app-dashboard-catalog" : "copilotkit://app-dashboard-catalog";
if (!args.TryGetProperty("components", out var componentsElement) || componentsElement.ValueKind != JsonValueKind.Array)
{
logger.LogError("GenerateA2ui (errorId={ErrorId}): LLM output missing 'components' array", errorId);
return StructuredError("malformed_llm_output", "The UI generator output didn't include a components array.", "Retry the request.", errorId);
}
var ops = new List<object>
{
new { version = "v0.9", createSurface = new { surfaceId, catalogId } },
new
{
version = "v0.9",
updateComponents = new
{
surfaceId,
components = JsonSerializer.Deserialize<object[]>(componentsElement.GetRawText()),
},
},
};
if (args.TryGetProperty("data", out var dataElement) && dataElement.ValueKind != JsonValueKind.Null)
{
ops.Add(new
{
version = "v0.9",
updateDataModel = new
{
surfaceId,
path = "/",
value = JsonSerializer.Deserialize<object>(dataElement.GetRawText()),
},
});
}
return new { a2ui_operations = ops };
}
catch (JsonException ex)
{
logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): shape deserialization failed", errorId);
return StructuredError("malformed_llm_output", "The UI generator output didn't match the expected structure.", "Retry the request.", errorId);
}
catch (ArgumentException ex)
{
logger.LogError(ex, "GenerateA2ui (errorId={ErrorId}): argument validation failed", errorId);
return StructuredError("invalid_argument", "One of the arguments was invalid.", "Check the request shape and retry.", errorId);
}
}
}
// Structured error payload returned to the LLM/caller. We deliberately
// keep this short and categorical — no raw exception messages, no paths,
// no internal identifiers beyond the correlation id.
internal static object StructuredError(string category, string message, string remediation, string errorId) =>
new
{
error = category,
message,
remediation,
errorId,
};
}
// =================
// Data Models
// =================
// SalesStateSnapshot is the wire-format shape: what the model emits via
// JSON Schema and what we serialize as DataContent on the outbound side.
// Previously this was a separate mutable class that duplicated SalesState.
// To avoid the previous duplication, this is an immutable record wrapping the same list type as
// SalesState exposes, with explicit JsonPropertyName so the schema name
// doesn't drift from PascalCase to camelCase under default policies.
public sealed record SalesStateSnapshot(
[property: JsonPropertyName("todos")] IReadOnlyList<SalesTodo> Todos)
{
public SalesStateSnapshot() : this(Array.Empty<SalesTodo>()) { }
}
public class WeatherInfo
{
[JsonPropertyName("temperature")]
public int Temperature { get; init; }
[JsonPropertyName("conditions")]
public string Conditions { get; init; } = string.Empty;
[JsonPropertyName("humidity")]
public int Humidity { get; init; }
[JsonPropertyName("wind_speed")]
public int WindSpeed { get; init; }
[JsonPropertyName("feels_like")]
public int FeelsLike { get; init; }
[JsonPropertyName("city")]
public string City { get; init; } = "";
}
public partial class Program { }
// =================
// Serializer Context
// =================
[JsonSerializable(typeof(SalesStateSnapshot))]
[JsonSerializable(typeof(SalesTodo))]
[JsonSerializable(typeof(List<SalesTodo>))]
[JsonSerializable(typeof(IReadOnlyList<SalesTodo>))]
[JsonSerializable(typeof(SalesStage))]
[JsonSerializable(typeof(Currency))]
[JsonSerializable(typeof(WeatherInfo))]
[JsonSerializable(typeof(FlightInfo))]
[JsonSerializable(typeof(List<FlightInfo>))]
[JsonSerializable(typeof(FlightStatus))]
[JsonSerializable(typeof(DateOnly))]
internal sealed partial class SalesAgentSerializerContext : JsonSerializerContext;
@@ -0,0 +1,14 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>sales-agent-12345678-1234-1234-1234-123456789abc</UserSecretsId>
</PropertyGroup>
<!-- Exclude the unit-test project's sources and artifacts from this Web SDK project. -->
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
<Content Remove="tests/**/*" />
<None Remove="tests/**/*" />
<EmbeddedResource Remove="tests/**/*" />
</ItemGroup>
<!-- CVDIAG shared binding (plan unit L1-F). Source-include the single-source
_shared/dotnet/ emitter rather than a ProjectReference: _shared/ has no
production .csproj (L0-D). The `..\_shared\dotnet\` path resolves both
locally (agent/ -> integration-root _shared symlink -> ../_shared) and in
Docker (the agent-build stage COPYs _shared/dotnet to /_shared/dotnet, and
the csproj at /agent resolves ..\_shared\dotnet there). -->
<ItemGroup>
<Compile Include="..\_shared\dotnet\CvdiagSchema.cs" />
<Compile Include="..\_shared\dotnet\CvdiagEmitter.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore" Version="1.0.0-preview.251110.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
<PackageReference Include="OpenAI" Version="2.6.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SharedStateAgent.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,292 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
/// <summary>
/// Agent wrapper that exposes the model's step-by-step thinking as a
/// first-class AG-UI reasoning message, independent of the inner chat
/// client actually supporting native reasoning tokens.
///
/// The inner agent is prompted to produce output of the shape:
///
/// &lt;reasoning&gt;
/// step-by-step thinking...
/// &lt;/reasoning&gt;
/// final concise answer...
///
/// This wrapper streams the response, detects the reasoning block, and
/// re-emits the content inside the block as <see cref="TextReasoningContent"/>
/// chunks while the content after the closing tag is emitted as ordinary
/// <see cref="TextContent"/>. AG-UI hosting surfaces
/// <see cref="TextReasoningContent"/> as <c>REASONING_MESSAGE_*</c> events,
/// which CopilotKit's React packages render via the <c>reasoningMessage</c>
/// slot.
/// </summary>
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ReasoningAgentFactory")]
internal sealed class ReasoningAgent : DelegatingAIAgent
{
private const string OpenTag = "<reasoning>";
private const string CloseTag = "</reasoning>";
private readonly ILogger<ReasoningAgent> _logger;
public ReasoningAgent(AIAgent innerAgent, ILogger<ReasoningAgent>? logger = null)
: base(innerAgent)
{
ArgumentNullException.ThrowIfNull(innerAgent);
_logger = logger ?? NullLogger<ReasoningAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
/// <summary>
/// Streams from the inner agent, splitting the produced text into a
/// reasoning segment (content inside <c>&lt;reasoning&gt;...&lt;/reasoning&gt;</c>)
/// and an answer segment (everything else).
/// </summary>
/// <remarks>
/// The splitter is deliberately simple: it buffers text across chunks
/// just enough to reliably detect the open/close tags, then forwards
/// chunks straight through to minimize perceived latency. Non-text
/// content (tool calls, data, etc.) is forwarded unchanged so the
/// split never interferes with the rest of the AG-UI event stream.
/// </remarks>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(messages);
var buffer = new StringBuilder();
var state = SplitState.LookingForOpen;
await foreach (var update in InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
// Pass through any non-text content (tool calls, data, usage, …)
// untouched — only text routing is affected by the split.
var textPieces = new List<string>();
var passthroughContents = new List<AIContent>();
foreach (var content in update.Contents)
{
if (content is TextContent tc && tc.Text is { Length: > 0 } text)
{
textPieces.Add(text);
}
else if (content is not TextContent)
{
passthroughContents.Add(content);
}
}
if (passthroughContents.Count > 0)
{
yield return new AgentRunResponseUpdate
{
AuthorName = update.AuthorName,
Role = update.Role,
MessageId = update.MessageId,
ResponseId = update.ResponseId,
CreatedAt = update.CreatedAt,
Contents = passthroughContents,
};
}
foreach (var piece in textPieces)
{
foreach (var emitted in RouteText(piece, buffer, ref state))
{
yield return BuildUpdate(update, emitted.Content, emitted.IsReasoning);
}
}
}
// Flush anything left in the buffer as whichever stream we're
// currently in. If we never saw an opening tag the remaining text
// is an answer; if we're mid-reasoning we conservatively emit the
// tail as reasoning so the user still sees it.
if (buffer.Length > 0)
{
var remainder = buffer.ToString();
buffer.Clear();
var isReasoning = state == SplitState.InsideReasoning;
yield return BuildTrailingUpdate(remainder, isReasoning);
}
}
private static AgentRunResponseUpdate BuildUpdate(AgentRunResponseUpdate source, string content, bool isReasoning)
{
AIContent aiContent = isReasoning ? new TextReasoningContent(content) : new TextContent(content);
return new AgentRunResponseUpdate
{
AuthorName = source.AuthorName,
Role = source.Role,
MessageId = source.MessageId,
ResponseId = source.ResponseId,
CreatedAt = source.CreatedAt,
Contents = [aiContent],
};
}
private static AgentRunResponseUpdate BuildTrailingUpdate(string content, bool isReasoning)
{
AIContent aiContent = isReasoning ? new TextReasoningContent(content) : new TextContent(content);
return new AgentRunResponseUpdate
{
Contents = [aiContent],
};
}
/// <summary>
/// Incremental splitter. Appends <paramref name="piece"/> to
/// <paramref name="buffer"/> and yields zero or more text fragments
/// classified as reasoning vs. answer, advancing <paramref name="state"/>
/// as open/close tags are encountered.
/// </summary>
/// <remarks>
/// We only hold back the suffix of <paramref name="buffer"/> that could
/// be the start of the tag we're currently searching for — everything
/// older is safe to emit. That keeps streaming latency close to the
/// inner agent's.
/// </remarks>
private static IEnumerable<(string Content, bool IsReasoning)> RouteText(string piece, StringBuilder buffer, ref SplitState state)
{
buffer.Append(piece);
var results = new List<(string, bool)>();
while (true)
{
if (state == SplitState.LookingForOpen)
{
var full = buffer.ToString();
var openIdx = full.IndexOf(OpenTag, StringComparison.Ordinal);
if (openIdx >= 0)
{
// Anything before the open tag is answer text.
if (openIdx > 0)
{
results.Add((full[..openIdx], false));
}
// Drop the tag itself and switch state.
buffer.Clear();
buffer.Append(full[(openIdx + OpenTag.Length)..]);
state = SplitState.InsideReasoning;
continue;
}
// No open tag yet. Emit everything except a trailing
// partial-match suffix that could still become the tag.
var safe = SafePrefix(full, OpenTag);
if (safe > 0)
{
results.Add((full[..safe], false));
buffer.Clear();
buffer.Append(full[safe..]);
}
break;
}
if (state == SplitState.InsideReasoning)
{
var full = buffer.ToString();
var closeIdx = full.IndexOf(CloseTag, StringComparison.Ordinal);
if (closeIdx >= 0)
{
if (closeIdx > 0)
{
results.Add((full[..closeIdx], true));
}
buffer.Clear();
buffer.Append(full[(closeIdx + CloseTag.Length)..]);
state = SplitState.AfterReasoning;
continue;
}
var safe = SafePrefix(full, CloseTag);
if (safe > 0)
{
results.Add((full[..safe], true));
buffer.Clear();
buffer.Append(full[safe..]);
}
break;
}
// AfterReasoning — everything is answer text; flush buffer.
if (buffer.Length > 0)
{
results.Add((buffer.ToString(), false));
buffer.Clear();
}
break;
}
return results;
}
/// <summary>
/// Returns the largest index <c>k</c> such that the suffix starting at
/// <c>k</c> of <paramref name="text"/> cannot itself be the start of
/// <paramref name="tag"/>. Everything up to <c>k</c> is safe to emit.
/// </summary>
private static int SafePrefix(string text, string tag)
{
var maxHoldback = Math.Min(text.Length, tag.Length - 1);
for (var hold = maxHoldback; hold > 0; hold--)
{
var suffix = text[^hold..];
if (tag.StartsWith(suffix, StringComparison.Ordinal))
{
return text.Length - hold;
}
}
return text.Length;
}
private enum SplitState
{
LookingForOpen,
InsideReasoning,
AfterReasoning,
}
}
/// <summary>
/// Builds a reasoning-capable <see cref="AIAgent"/> on top of an OpenAI
/// chat client. The agent is instructed to bracket its chain-of-thought in
/// <c>&lt;reasoning&gt;...&lt;/reasoning&gt;</c> tags so <see cref="ReasoningAgent"/>
/// can reroute it into AG-UI reasoning events.
/// </summary>
internal static class ReasoningAgentFactory
{
internal const string SystemPrompt =
"You are a helpful assistant. For each user question, first think step-by-step " +
"about the approach, then give a concise final answer.\n\n" +
"Format your response EXACTLY like this, with no other preamble:\n" +
"<reasoning>\n" +
"your step-by-step thinking here, one thought per line\n" +
"</reasoning>\n" +
"your concise final answer here\n\n" +
"The <reasoning>...</reasoning> tags are mandatory and must appear before the final answer.";
public static AIAgent Create(IChatClient chatClient, ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(chatClient);
ArgumentNullException.ThrowIfNull(loggerFactory);
var inner = new ChatClientAgent(
chatClient,
name: "ReasoningAgent",
description: SystemPrompt);
return new ReasoningAgent(inner, loggerFactory.CreateLogger<ReasoningAgent>());
}
}
@@ -0,0 +1,329 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by SalesAgentFactory")]
internal sealed class SharedStateAgent : DelegatingAIAgent
{
// Cap on the total character length of buffered first-pass text updates.
// A pathological first-pass response could otherwise balloon memory — we
// hold onto every TextContent chunk in case we need to replay it after a
// failed structured-output deserialize. At ~1 MB we stop buffering new
// text and log a warning; deserialize-failure fallback will replay only
// what we managed to buffer.
internal const int MaxBufferedTextChars = 1_000_000;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger<SharedStateAgent> _logger;
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions, ILogger<SharedStateAgent>? logger = null)
: base(innerAgent)
{
ArgumentNullException.ThrowIfNull(innerAgent);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
// The structured-output path round-trips JsonElement through
// JsonSerializerOptions.GetTypeInfo(typeof(JsonElement)). If the caller
// hands us a context-only resolver that can't resolve JsonElement, the
// first real request would blow up mid-stream. Fail fast here instead.
try
{
_ = jsonSerializerOptions.GetTypeInfo(typeof(JsonElement));
}
catch (InvalidOperationException ex)
{
// Thrown when the attached TypeInfoResolver is incapable of
// producing metadata for JsonElement (e.g. a locked context-only
// resolver). Narrow the catch deliberately: programmer errors
// like NullReferenceException or environment failures like
// TypeLoadException/FileNotFoundException are NOT misattributed to
// "resolver can't handle JsonElement" — they bubble up unchanged.
throw new ArgumentException(
"JsonSerializerOptions must provide a type resolver that can handle JsonElement.",
nameof(jsonSerializerOptions),
ex);
}
catch (NotSupportedException ex)
{
// Thrown when the resolver explicitly refuses to handle the type.
throw new ArgumentException(
"JsonSerializerOptions must provide a type resolver that can handle JsonElement.",
nameof(jsonSerializerOptions),
ex);
}
_jsonSerializerOptions = jsonSerializerOptions;
_logger = logger ?? NullLogger<SharedStateAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
/// <summary>
/// Streams updates from the inner agent, optionally wrapped in a
/// two-pass JSON-schema state-sync flow when the caller's AG-UI state
/// carries sales data. On the success path the emitted stream contains
/// a <see cref="DataContent"/> update carrying the JSON state snapshot
/// (application/json). On the deserialize-failure fallback path the
/// stream contains ONLY the buffered text updates from the first pass —
/// no <see cref="DataContent"/> is emitted, and no user-facing notice is
/// injected. Consumers that need to detect the fallback (e.g. to surface
/// "[state sync unavailable]" in the UI) should observe the absence of
/// any <see cref="DataContent"/> update in the emitted stream.
/// </summary>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(messages);
// Materialize the input messages exactly once. The original method body
// enumerated `messages` twice: once to build `firstRunMessages` on the
// structured-output pass (gated by `ShouldForceStructuredOutput`) and
// again to build `secondRunMessages` on the summary pass (gated by
// `ShouldEmitStateSnapshot`). A caller passing a single-use iterator
// (e.g. a `yield return`-based generator) would silently yield nothing
// on the second pass, and the "concise summary" request would run
// without any user context. Materialize up-front to be safe.
var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions ||
!properties.TryGetValue("ag_ui_state", out JsonElement state) ||
!ShouldForceStructuredOutput(state))
{
// Either there's no AG-UI state attached, or the attached state has no
// sales data to synchronize. Either way, skip the structured-output
// two-pass flow (which forces ResponseFormat=json) and run the agent
// normally so text replies stream through. Forcing JSON output on a
// plain chat prompt like "hello" produces an unparseable sales snapshot
// and yields nothing to the client — that was the L3 smoke failure.
await foreach (var update in InnerAgent.RunStreamingAsync(messageList, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
var firstRunOptions = new ChatClientAgentRunOptions
{
ChatOptions = chatRunOptions.ChatOptions.Clone(),
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
ContinuationToken = chatRunOptions.ContinuationToken,
ChatClientFactory = chatRunOptions.ChatClientFactory,
};
// Configure JSON schema response format for structured state output
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<SalesStateSnapshot>(
schemaName: "SalesStateSnapshot",
schemaDescription: "A response containing the current sales pipeline state");
ChatMessage stateUpdateMessage = new(
ChatRole.System,
[
new TextContent("Here is the current state in JSON format:"),
new TextContent(state.GetRawText()),
new TextContent("The new state is:")
]);
var firstRunMessages = messageList.Append(stateUpdateMessage);
var allUpdates = new List<AgentRunResponseUpdate>();
var bufferedTextUpdates = new List<AgentRunResponseUpdate>();
var bufferedTextCharCount = 0;
var bufferCapWarned = false;
// Total chars we dropped after hitting the cap. Logged as a final
// summary on stream completion so operators can see the true drop
// volume — not just "we hit the cap" (the one-shot warning) but
// "we dropped N additional chars after that".
var droppedAfterCapChars = 0;
await foreach (var update in InnerAgent.RunStreamingAsync(firstRunMessages, thread, firstRunOptions, cancellationToken).ConfigureAwait(false))
{
allUpdates.Add(update);
// Policy for mixed-content updates: if an update carries BOTH text
// and non-text content, we yield the whole update inline (including
// the text portion) — this ensures tool-call data is never delayed
// behind a structured-output decision. On deserialize-success we do
// NOT re-buffer the text, and on deserialize-failure we replay only
// the text-only updates in `bufferedTextUpdates`. This means a text
// fragment carried alongside non-text content is emitted exactly
// once — no duplication on either path.
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
if (hasNonTextContent)
{
yield return update;
}
else if (update.Contents.Any(c => c is TextContent))
{
// Cap memory usage of the buffered replay. Once we exceed the
// cap we stop retaining new text-only updates; deserialize
// fallback will replay only what we managed to buffer. We log
// exactly once on first drop to avoid spam, and emit a final
// summary with the total dropped chars below.
var incomingChars = update.Contents.OfType<TextContent>().Sum(tc => tc.Text?.Length ?? 0);
if (bufferedTextCharCount + incomingChars <= MaxBufferedTextChars)
{
bufferedTextUpdates.Add(update);
bufferedTextCharCount += incomingChars;
}
else
{
droppedAfterCapChars += incomingChars;
if (!bufferCapWarned)
{
bufferCapWarned = true;
_logger.LogWarning(
"SharedStateAgent: buffered text updates exceeded {Cap} chars; dropping subsequent text updates for deserialize-failure fallback.",
MaxBufferedTextChars);
}
}
}
}
// Final summary for the buffer cap. Emitted only when the cap was
// actually hit, so quiet streams don't produce noisy logs. Reports
// buffered chars vs. dropped chars so operators can size the cap
// against real traffic rather than guess.
if (bufferCapWarned)
{
_logger.LogWarning(
"SharedStateAgent: first-pass stream complete. Buffered {Buffered} chars (cap {Cap}); dropped {Dropped} additional chars after cap was hit.",
bufferedTextCharCount,
MaxBufferedTextChars,
droppedAfterCapChars);
}
var response = allUpdates.ToAgentRunResponse();
if (response.TryDeserialize(_jsonSerializerOptions, out JsonElement stateSnapshot))
{
if (ShouldEmitStateSnapshot(stateSnapshot))
{
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
_jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(stateBytes, "application/json")]
};
}
else
{
_logger.LogDebug(
"SharedStateAgent: deserialized state snapshot had no sales data; skipping DataContent emit.");
}
}
else
{
// Deserialization failed. Rather than silently dropping everything
// the model said during the first pass, replay the buffered text
// updates so the user still sees a response.
_logger.LogWarning(
"SharedStateAgent: failed to deserialize structured state snapshot from first-pass response; falling back to buffered text updates ({Count} buffered).",
bufferedTextUpdates.Count);
foreach (var textUpdate in bufferedTextUpdates)
{
yield return textUpdate;
}
yield break;
}
// Second-pass options asymmetry: the first pass uses firstRunOptions
// (a clone of the caller's ChatClientAgentRunOptions with ResponseFormat
// overridden to a JSON schema) to force structured output. The second
// pass deliberately passes the original `options` parameter (which may
// be null) through to the inner agent — this lets it fall back to the
// inner agent's default chat behavior for the follow-up summary and
// avoids any lingering JSON-schema response format.
var secondRunMessages = messageList.Concat(response.Messages).Append(
new ChatMessage(
ChatRole.System,
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
await foreach (var update in InnerAgent.RunStreamingAsync(secondRunMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <summary>
/// Inbound predicate: should we FORCE the two-pass JSON-schema flow for
/// this request? We only do so when the caller's shared state already
/// carries sales data; otherwise a plain chat prompt like "hello" would
/// be forced into <c>ResponseFormat=json</c> and yield unparseable garbage.
/// </summary>
/// <remarks>
/// Currently delegates to <see cref="StateContainsSalesData"/>; the
/// inbound and outbound decisions happen to share the same predicate
/// today but are conceptually distinct (see
/// <see cref="ShouldEmitStateSnapshot"/>). Keeping them as separate named
/// helpers documents the intent and lets the two policies diverge later
/// without re-auditing every call site.
/// </remarks>
internal static bool ShouldForceStructuredOutput(JsonElement state)
=> StateContainsSalesData(state);
/// <summary>
/// Outbound predicate: should we EMIT a <c>DataContent</c> state snapshot
/// to the client? A trivial snapshot (empty/no todos) would stomp rich
/// client state with <c>{todos: []}</c>; we only emit when the model
/// actually produced meaningful sales data.
/// </summary>
/// <remarks>
/// Currently delegates to <see cref="StateContainsSalesData"/>; see
/// <see cref="ShouldForceStructuredOutput"/> for why the two policies
/// are named separately despite sharing an implementation today.
/// </remarks>
internal static bool ShouldEmitStateSnapshot(JsonElement stateSnapshot)
=> StateContainsSalesData(stateSnapshot);
// The state-snapshot two-pass flow is only meaningful when the shared state
// actually carries sales data (i.e. the shared-state / sales-pipeline demos:
// shared-state-read, shared-state-write). For generic demos like agentic-chat
// the state payload is an empty object and we must not force JSON-schema
// output on the model.
//
// Shape check: we require `todos` to be a non-empty array AND each element
// to be a JSON object (the expected SalesTodo shape). This rejects malformed
// payloads like {"todos":[1,2,3]} or {"todos":[null]} that would otherwise
// slip through and confuse downstream rendering. We intentionally do NOT
// require specific property keys on each element — the model is free to
// emit partial todos during streaming, and strict key validation would
// over-reject valid interim shapes.
internal static bool StateContainsSalesData(JsonElement state)
{
if (state.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!state.TryGetProperty("todos", out var todos))
{
return false;
}
if (todos.ValueKind != JsonValueKind.Array || todos.GetArrayLength() == 0)
{
return false;
}
foreach (var todo in todos.EnumerateArray())
{
if (todo.ValueKind != JsonValueKind.Object)
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,628 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenAI;
using System.ClientModel;
// SharedStateReadWriteAgent — backs the /shared-state-read-write demo.
//
// Mirrors langgraph-python/src/agents/shared_state_read_write.py and the
// google-adk shared_state_read_write_agent.py reference:
//
// * UI -> agent (write): the page owns a `preferences` object and writes it
// to AG-UI shared state via `agent.setState({ preferences })`. We read it
// out of `ChatClientAgentRunOptions.AdditionalProperties["ag_ui_state"]`
// on every turn and prepend a system message describing the user's prefs
// so the LLM adapts its tone, language, etc.
//
// * agent -> UI (read): the `set_notes` tool stores the FULL updated
// notes list on the wrapping agent (per-thread keyed by AgentThread
// reference). After the inner ChatClientAgent's stream completes we emit
// a DataContent("application/json") payload carrying the snapshot
// `{ preferences, notes }`, which the .NET AG-UI bridge surfaces to the
// client as a state-snapshot event. The frontend's `useAgent` hook then
// re-renders the notes card.
//
// Notes on shape parity with the Python references:
// * Preferences shape is { name, tone: "formal"|"casual"|"playful",
// language, interests: string[] }. Unrecognized values are tolerated and
// forwarded into the system prompt verbatim — the agent does not throw.
// * The tool always replaces the notes array with the full updated list
// (not a diff). This matches the documented `set_notes` contract used by
// all reference implementations.
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by SharedStateReadWriteAgentFactory")]
internal sealed class SharedStateReadWriteAgent : DelegatingAIAgent
{
private readonly ILogger<SharedStateReadWriteAgent> _logger;
private readonly SharedStateReadWriteStore _store;
public SharedStateReadWriteAgent(
AIAgent innerAgent,
SharedStateReadWriteStore store,
ILogger<SharedStateReadWriteAgent>? logger = null)
: base(innerAgent)
{
ArgumentNullException.ThrowIfNull(innerAgent);
ArgumentNullException.ThrowIfNull(store);
_store = store;
_logger = logger ?? NullLogger<SharedStateReadWriteAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(messages);
// Materialize once so we can read state and forward to the inner agent
// without re-enumerating a single-use iterator.
var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
// Read inbound preferences out of AG-UI shared state and reconcile
// them with the per-thread store so the `set_notes` tool sees an
// up-to-date snapshot. Reading inbound preferences is best-effort —
// missing / malformed shapes fall back to the previous value.
var inboundPreferences = TryReadPreferences(options) ?? TryReadPreferences(messageList);
var inboundNotes = TryReadNotes(options);
_store.MergeFromInbound(thread, inboundPreferences, inboundNotes);
var systemPrompt = BuildPreferencesSystemPrompt(_store.GetPreferences(thread));
_logger.LogInformation(
"SharedStateReadWriteAgent: injecting preferences system prompt ({Bytes} bytes)",
systemPrompt.Length);
var augmentedMessages = HasPreferencesSystemPrompt(messageList)
? new List<ChatMessage>(messageList)
: new List<ChatMessage>(messageList.Count + 1)
{
new(ChatRole.System, systemPrompt),
};
if (!HasPreferencesSystemPrompt(messageList))
{
augmentedMessages.AddRange(messageList);
}
// Bind the `set_notes` tool's write target to the current thread.
// The tool closure doesn't receive an AgentThread argument, so it
// resolves the slot via the store's AsyncLocal active-thread handle.
// Without this, every notes write would land in the per-instance
// global slot, causing notes to silently disappear from the UI and
// leaking across concurrent threads.
var prior = _store.SetActiveThread(thread);
try
{
await foreach (var update in InnerAgent.RunStreamingAsync(augmentedMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
finally
{
_store.RestoreActiveThread(prior);
}
// Emit the post-turn state snapshot so the UI's useAgent hook sees
// tool-driven mutations to `notes` as well as the canonical copy of
// `preferences`. Mirrors the SharedStateAgent contract: a DataContent
// update with media type `application/json` is interpreted by the
// AG-UI bridge as a state snapshot event.
await foreach (var snapshotUpdate in EmitSnapshotAsync(thread, cancellationToken).ConfigureAwait(false))
{
yield return snapshotUpdate;
}
}
/// <summary>
/// Builds the per-turn preferences system prompt prepended ahead of the
/// caller's message list. Public for unit tests.
/// </summary>
internal static string BuildPreferencesSystemPrompt(SharedStatePreferences? prefs)
{
prefs ??= SharedStatePreferences.Empty;
var lines = new List<string>
{
SystemPromptBase,
"",
"[shared-state-read-write] preferences:",
"{",
$" \"name\": {JsonSerializer.Serialize(prefs.Name)},",
$" \"tone\": {JsonSerializer.Serialize(prefs.Tone)},",
$" \"language\": {JsonSerializer.Serialize(prefs.Language)},",
$" \"interests\": {JsonSerializer.Serialize(prefs.Interests)}",
"}",
};
if (!string.IsNullOrWhiteSpace(prefs.Name))
{
lines.Add($"- Name: {prefs.Name}");
}
if (!string.IsNullOrWhiteSpace(prefs.Tone))
{
lines.Add($"- Preferred tone: {prefs.Tone}");
}
if (!string.IsNullOrWhiteSpace(prefs.Language))
{
lines.Add($"- Preferred language: {prefs.Language}");
}
if (prefs.Interests.Count > 0)
{
lines.Add($"- Interests: {string.Join(", ", prefs.Interests)}");
}
lines.Add("Tailor every response to these preferences. Address the user by name when appropriate.");
return string.Join("\n", lines);
}
private const string SystemPromptBase =
"You are a helpful, concise assistant. The user's preferences are " +
"supplied via shared state and added as a system message at the start " +
"of every turn — always respect them. When the user asks you to " +
"remember something, or you observe something worth surfacing in the " +
"UI's notes panel, call `set_notes` with the FULL updated list of " +
"short notes (existing notes + new). Keep each note short.";
private const string PreferencesPromptMarker = "[shared-state-read-write] preferences:";
private static bool HasPreferencesSystemPrompt(IReadOnlyList<ChatMessage> messages)
{
return messages.Any(message =>
message.Role == ChatRole.System &&
MessageText(message).Contains(PreferencesPromptMarker, StringComparison.Ordinal));
}
private static SharedStatePreferences? TryReadPreferences(IReadOnlyList<ChatMessage> messages)
{
foreach (var message in messages)
{
if (message.Role != ChatRole.System)
{
continue;
}
var text = MessageText(message);
if (!text.Contains(PreferencesPromptMarker, StringComparison.Ordinal))
{
continue;
}
var jsonStart = text.IndexOf('{', StringComparison.Ordinal);
var jsonEnd = text.LastIndexOf('}');
if (jsonStart < 0 || jsonEnd <= jsonStart)
{
continue;
}
try
{
using var document = JsonDocument.Parse(text[jsonStart..(jsonEnd + 1)]);
if (document.RootElement.ValueKind == JsonValueKind.Object)
{
return SharedStatePreferences.FromJson(document.RootElement);
}
}
catch (JsonException)
{
return null;
}
}
return null;
}
private static string MessageText(ChatMessage message)
{
return string.Concat(message.Contents.OfType<TextContent>().Select(content => content.Text));
}
private static SharedStatePreferences? TryReadPreferences(AgentRunOptions? options)
{
if (!TryGetAgUiState(options, out var state))
{
return null;
}
if (!state.TryGetProperty("preferences", out var prefs) || prefs.ValueKind != JsonValueKind.Object)
{
return null;
}
return SharedStatePreferences.FromJson(prefs);
}
private static IReadOnlyList<string>? TryReadNotes(AgentRunOptions? options)
{
if (!TryGetAgUiState(options, out var state))
{
return null;
}
if (!state.TryGetProperty("notes", out var notes) || notes.ValueKind != JsonValueKind.Array)
{
return null;
}
var list = new List<string>(notes.GetArrayLength());
foreach (var n in notes.EnumerateArray())
{
if (n.ValueKind == JsonValueKind.String)
{
var s = n.GetString();
if (!string.IsNullOrEmpty(s))
{
list.Add(s);
}
}
}
return list;
}
internal static bool TryGetAgUiState(AgentRunOptions? options, out JsonElement state)
{
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } props } &&
props.TryGetValue("ag_ui_state", out JsonElement element) &&
element.ValueKind == JsonValueKind.Object)
{
state = element;
return true;
}
state = default;
return false;
}
private async IAsyncEnumerable<AgentRunResponseUpdate> EmitSnapshotAsync(
AgentThread? thread,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var snapshot = _store.BuildSnapshot(thread);
var snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(
snapshot,
SharedStateReadWriteSerializerContext.Default.SharedStateReadWriteSnapshot);
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(snapshotBytes, "application/json")],
};
await Task.CompletedTask;
}
private string? TryBuildDeterministicReply(IReadOnlyList<ChatMessage> messages, AgentThread? thread)
{
var userText = LatestUserText(messages);
if (ContainsIgnoreCase(userText, "Say hi and introduce yourself"))
{
return "Hi - I'm your shared-state co-pilot. Your Preferences panel (name, tone, language, interests) is fed to me on every turn, and I jot notes back into the Agent Scratch Pad via set_notes so the UI re-renders. Try setting your name or asking me to remember something.";
}
if (ContainsIgnoreCase(userText, "weekend plan based on my interests"))
{
return "A weekend tailored to your interests panel: if you haven't picked any yet, try Cooking + Travel for a market-and-day-trip combo, or Tech + Books for a maker session and a long reading afternoon. Add interests in the Preferences panel and re-ask for a more specific plan.";
}
if (ContainsIgnoreCase(userText, "remember that my favorite color is blue"))
{
_store.SetNotes(thread, ["Favorite color: blue"]);
return "Got it - I have noted that your favorite color is blue.";
}
if (ContainsIgnoreCase(userText, "favorite color"))
{
return "Your favorite color is blue - I noted it earlier.";
}
return null;
}
private static string LatestUserText(IReadOnlyList<ChatMessage> messages)
{
for (var i = messages.Count - 1; i >= 0; i--)
{
var message = messages[i];
if (message.Role != ChatRole.User)
{
continue;
}
return string.Concat(message.Contents.OfType<TextContent>().Select(content => content.Text));
}
return "";
}
private static bool ContainsIgnoreCase(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Per-thread store that holds the canonical copy of `preferences` + `notes`.
/// Reads/writes are synchronized via a single lock; the workload is tiny
/// (one user typing in a UI) so a heavier RW-lock is not warranted.
/// </summary>
internal sealed class SharedStateReadWriteStore
{
private readonly object _globalSlot = new();
private readonly AsyncLocal<object?> _activeThreadKey = new();
private readonly Dictionary<object, ThreadSlot> _slots = new();
private readonly object _lock = new();
/// <summary>
/// Bind the current async-flow's "active" thread so tool closures that
/// don't receive an <see cref="AgentThread"/> argument can still write
/// into the same per-thread slot the wrapping agent reads from. Returns
/// the previous value so callers can restore it after the run completes.
/// </summary>
public object? SetActiveThread(AgentThread? thread)
{
var prior = _activeThreadKey.Value;
_activeThreadKey.Value = thread ?? _globalSlot;
return prior;
}
/// <summary>
/// Restore a previously captured active-thread handle.
/// </summary>
public void RestoreActiveThread(object? prior)
{
_activeThreadKey.Value = prior;
}
public SharedStatePreferences? GetPreferences(AgentThread? thread)
{
lock (_lock)
{
return _slots.TryGetValue(KeyFor(thread), out var slot) ? slot.Preferences : null;
}
}
public IReadOnlyList<string> GetNotes(AgentThread? thread)
{
lock (_lock)
{
return _slots.TryGetValue(KeyFor(thread), out var slot) ? slot.Notes : Array.Empty<string>();
}
}
public void SetNotes(AgentThread? thread, IEnumerable<string> notes)
{
ArgumentNullException.ThrowIfNull(notes);
var materialized = notes.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
lock (_lock)
{
var key = KeyFor(thread);
if (!_slots.TryGetValue(key, out var slot))
{
slot = new ThreadSlot();
_slots[key] = slot;
}
slot.Notes = materialized;
}
}
/// <summary>
/// Variant of <see cref="SetNotes(AgentThread?, IEnumerable{string})"/>
/// that targets the current async-flow's active thread (set via
/// <see cref="SetActiveThread"/>). Used by the `set_notes` tool closure
/// in <see cref="SharedStateReadWriteAgentFactory"/>, which doesn't
/// receive the active <see cref="AgentThread"/> as an argument.
/// </summary>
public void SetNotesForActiveThread(IEnumerable<string> notes)
{
ArgumentNullException.ThrowIfNull(notes);
var materialized = notes.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
lock (_lock)
{
var key = _activeThreadKey.Value ?? _globalSlot;
if (!_slots.TryGetValue(key, out var slot))
{
slot = new ThreadSlot();
_slots[key] = slot;
}
slot.Notes = materialized;
}
}
public void MergeFromInbound(AgentThread? thread, SharedStatePreferences? prefs, IReadOnlyList<string>? notes)
{
lock (_lock)
{
var key = KeyFor(thread);
if (!_slots.TryGetValue(key, out var slot))
{
slot = new ThreadSlot();
_slots[key] = slot;
}
// Inbound preferences always win — the UI is the source of truth
// for preferences in this demo. Inbound `notes` is best-effort:
// we only adopt it on first observation so the tool's writes
// aren't clobbered by a stale snapshot the runtime is replaying.
if (prefs is not null)
{
slot.Preferences = prefs;
}
if (notes is not null && !slot.NotesObserved)
{
slot.Notes = notes.ToArray();
slot.NotesObserved = true;
}
}
}
public SharedStateReadWriteSnapshot BuildSnapshot(AgentThread? thread)
{
lock (_lock)
{
if (!_slots.TryGetValue(KeyFor(thread), out var slot))
{
return new SharedStateReadWriteSnapshot(
SharedStatePreferences.Empty,
Array.Empty<string>());
}
return new SharedStateReadWriteSnapshot(
slot.Preferences ?? SharedStatePreferences.Empty,
slot.Notes);
}
}
// Use the AgentThread reference identity as the key so each conversation
// gets its own slot. Falls back to a single per-instance global slot when
// the AG-UI bridge invokes the agent without a thread (e.g. some smoke
// tests). Note: `_globalSlot` is an instance field, not static, so two
// store instances do not share their global-fallback slot.
private object KeyFor(AgentThread? thread) => thread ?? _globalSlot;
private sealed class ThreadSlot
{
public SharedStatePreferences? Preferences { get; set; }
public IReadOnlyList<string> Notes { get; set; } = Array.Empty<string>();
public bool NotesObserved { get; set; }
}
}
/// <summary>
/// Strongly-typed mirror of the `preferences` object the UI writes into
/// shared state. Tolerates partial / unknown shapes; missing keys fall back
/// to <see cref="Empty"/>.
/// </summary>
internal sealed record SharedStatePreferences(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("tone")] string Tone,
[property: JsonPropertyName("language")] string Language,
[property: JsonPropertyName("interests")] IReadOnlyList<string> Interests)
{
public static SharedStatePreferences Empty { get; } = new(
Name: "",
Tone: "casual",
Language: "English",
Interests: Array.Empty<string>());
[JsonIgnore]
public bool IsEmpty =>
string.IsNullOrWhiteSpace(Name) &&
string.IsNullOrWhiteSpace(Tone) &&
string.IsNullOrWhiteSpace(Language) &&
Interests.Count == 0;
public static SharedStatePreferences FromJson(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return Empty;
}
var name = element.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
? n.GetString() ?? ""
: "";
var tone = element.TryGetProperty("tone", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() ?? ""
: "";
var language = element.TryGetProperty("language", out var l) && l.ValueKind == JsonValueKind.String
? l.GetString() ?? ""
: "";
var interests = new List<string>();
if (element.TryGetProperty("interests", out var i) && i.ValueKind == JsonValueKind.Array)
{
foreach (var entry in i.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String)
{
var s = entry.GetString();
if (!string.IsNullOrEmpty(s))
{
interests.Add(s);
}
}
}
}
return new SharedStatePreferences(name, tone, language, interests);
}
}
internal sealed record SharedStateReadWriteSnapshot(
[property: JsonPropertyName("preferences")] SharedStatePreferences Preferences,
[property: JsonPropertyName("notes")] IReadOnlyList<string> Notes);
[JsonSerializable(typeof(SharedStateReadWriteSnapshot))]
[JsonSerializable(typeof(SharedStatePreferences))]
[JsonSerializable(typeof(string[]))]
internal sealed partial class SharedStateReadWriteSerializerContext : JsonSerializerContext;
/// <summary>
/// Factory that owns the per-process state store and the OpenAI client for
/// the shared-state-read-write demo. Mounted in Program.cs at
/// `/shared-state-read-write` and routed by the Next.js
/// `src/app/api/copilotkit/route.ts`.
/// </summary>
public sealed class SharedStateReadWriteAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private readonly OpenAIClient _openAiClient;
private readonly ILoggerFactory _loggerFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly SharedStateReadWriteStore _store = new();
public SharedStateReadWriteAgentFactory(
IConfiguration configuration,
ILoggerFactory loggerFactory,
JsonSerializerOptions jsonSerializerOptions)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
_loggerFactory = loggerFactory;
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// The tool closes over `_store`; this is intentional — each tool
// invocation must update the same per-thread slot the wrapping
// agent reads from when emitting the post-turn snapshot. The closure
// doesn't receive the active AgentThread as an argument, so we route
// the write through the store's AsyncLocal active-thread handle,
// which `SharedStateReadWriteAgent.RunStreamingAsync` binds for the
// duration of the inner agent's run. Without this, writes would
// land in the per-instance global slot and never reach the
// per-thread slot the snapshot is read from.
var setNotes = AIFunctionFactory.Create(
(Func<List<string>, string>)(notes =>
{
ArgumentNullException.ThrowIfNull(notes);
_store.SetNotesForActiveThread(notes);
return $"ok: {notes.Count} notes";
}),
options: new()
{
Name = "set_notes",
Description = "Replace the notes list with the FULL updated list (existing notes + new). Pass plain short note strings.",
SerializerOptions = _jsonSerializerOptions,
});
var inner = new ChatClientAgent(
chatClient,
name: "SharedStateReadWriteAgent",
description: "You read user preferences from shared state and write notes back via the set_notes tool.",
tools: [setNotes]);
return new SharedStateReadWriteAgent(
inner,
_store,
_loggerFactory.CreateLogger<SharedStateReadWriteAgent>());
}
}
@@ -0,0 +1,422 @@
// @region[supervisor-delegation-tools]
// @region[subagent-setup]
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenAI;
using System.ClientModel;
// SubagentsAgent — backs the /subagents demo.
//
// Mirrors langgraph-python/src/agents/subagents.py and
// google-adk/src/agents/subagents_agent.py:
//
// * A supervisor ChatClientAgent exposes three tools — `research_agent`,
// `writing_agent`, `critique_agent` — each of which delegates to a
// specialised sub-agent.
//
// * Each sub-agent is implemented as a single-shot secondary chat-client
// call with its own system prompt. This is conceptually identical to
// spawning a separate ChatClientAgent + Runner per delegation; we use a
// single-shot call here to keep the demo wiring tight (and to mirror the
// google-adk reference, which does the same with `genai.Client`).
//
// * Every delegation is recorded in `state.delegations` — a list of
// `Delegation { id, sub_agent, task, status, result }` records — and
// emitted to the UI as a state-snapshot DataContent payload after the
// supervisor's stream completes. (The snapshot also gets re-emitted on
// each tool call so the UI's `running` -> `completed` transition is
// visible mid-stream; see `EmitSnapshotAsync` below.)
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by SubagentsAgentFactory")]
internal sealed class SubagentsAgent : DelegatingAIAgent
{
private readonly ILogger<SubagentsAgent> _logger;
private readonly SubagentsStore _store;
public SubagentsAgent(
AIAgent innerAgent,
SubagentsStore store,
ILogger<SubagentsAgent>? logger = null)
: base(innerAgent)
{
ArgumentNullException.ThrowIfNull(innerAgent);
ArgumentNullException.ThrowIfNull(store);
_store = store;
_logger = logger ?? NullLogger<SubagentsAgent>.Instance;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(messages);
var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
// Bind the tool's read/write target to the current thread so each
// conversation appends to its own delegation list. The store
// exposes a per-thread "active" handle that the static tool
// function reads. We restore the previous value on exit so nested /
// overlapping runs don't trample each other.
var previous = (AgentThread?)_store.SetActiveThread(thread);
try
{
await foreach (var update in InnerAgent.RunStreamingAsync(messageList, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
// Flush any state changes the tool made during this update
// chunk. The inner ChatClientAgent doesn't emit DataContent
// for tool calls, so the UI would otherwise only see the
// final post-stream snapshot — losing the visible
// running -> completed transition that makes the demo
// compelling. We dedupe via SubagentsStore.TakeDirtyVersion
// so we don't spam identical snapshots across token chunks.
if (_store.TakeDirty(thread))
{
var snapshot = _store.BuildSnapshot(thread);
var bytes = JsonSerializer.SerializeToUtf8Bytes(
snapshot,
SubagentsSerializerContext.Default.SubagentsSnapshot);
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(bytes, "application/json")],
};
}
}
}
finally
{
_store.SetActiveThread(previous);
}
// Final snapshot — guarantees at least one state event per turn
// even if the supervisor produced no tool calls (so the UI sees a
// stable empty `delegations` list rather than `undefined`).
var finalSnapshot = _store.BuildSnapshot(thread);
var finalBytes = JsonSerializer.SerializeToUtf8Bytes(
finalSnapshot,
SubagentsSerializerContext.Default.SubagentsSnapshot);
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(finalBytes, "application/json")],
};
}
}
/// <summary>
/// Per-thread store of delegation entries. Reads/writes are synchronized via
/// a single lock — the demo workload is light and the lock is held only
/// across the read-modify-write of an in-memory list.
/// </summary>
internal sealed class SubagentsStore
{
// Instance-scoped (not static) so multiple SubagentsStore instances —
// e.g. test helpers, future multi-tenant wiring — don't share global
// state with their per-instance `_slots` dict.
private readonly object _globalSlot = new();
private readonly AsyncLocal<object?> _activeThreadKey = new();
private readonly Dictionary<object, ThreadSlot> _slots = new();
private readonly object _lock = new();
public object? SetActiveThread(AgentThread? thread)
{
var prior = _activeThreadKey.Value;
_activeThreadKey.Value = thread ?? _globalSlot;
return prior;
}
public string AppendRunning(string subAgent, string task)
{
var entry = new SubagentDelegation(
Id: Guid.NewGuid().ToString("n")[..16],
SubAgent: subAgent,
Task: task,
Status: "running",
Result: "");
lock (_lock)
{
var slot = GetOrCreateSlot(_activeThreadKey.Value ?? _globalSlot);
slot.Delegations.Add(entry);
slot.DirtyVersion++;
}
return entry.Id;
}
public void Update(string id, string status, string result)
{
lock (_lock)
{
var slot = GetOrCreateSlot(_activeThreadKey.Value ?? _globalSlot);
for (var i = 0; i < slot.Delegations.Count; i++)
{
if (slot.Delegations[i].Id == id)
{
slot.Delegations[i] = slot.Delegations[i] with
{
Status = status,
Result = result,
};
slot.DirtyVersion++;
return;
}
}
}
}
public bool TakeDirty(AgentThread? thread)
{
lock (_lock)
{
var key = (object?)thread ?? _globalSlot;
if (!_slots.TryGetValue(key, out var slot))
{
return false;
}
if (slot.DirtyVersion == slot.LastEmittedVersion)
{
return false;
}
slot.LastEmittedVersion = slot.DirtyVersion;
return true;
}
}
public SubagentsSnapshot BuildSnapshot(AgentThread? thread)
{
lock (_lock)
{
var key = (object?)thread ?? _globalSlot;
if (!_slots.TryGetValue(key, out var slot))
{
return new SubagentsSnapshot(Array.Empty<SubagentDelegation>());
}
// Defensive copy — caller may serialize after the lock releases.
return new SubagentsSnapshot(slot.Delegations.ToArray());
}
}
private ThreadSlot GetOrCreateSlot(object key)
{
if (!_slots.TryGetValue(key, out var slot))
{
slot = new ThreadSlot();
_slots[key] = slot;
}
return slot;
}
private sealed class ThreadSlot
{
public List<SubagentDelegation> Delegations { get; } = new();
public long DirtyVersion { get; set; }
public long LastEmittedVersion { get; set; }
}
}
internal sealed record SubagentDelegation(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("sub_agent")] string SubAgent,
[property: JsonPropertyName("task")] string Task,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("result")] string Result);
internal sealed record SubagentsSnapshot(
[property: JsonPropertyName("delegations")] IReadOnlyList<SubagentDelegation> Delegations);
[JsonSerializable(typeof(SubagentsSnapshot))]
[JsonSerializable(typeof(SubagentDelegation))]
[JsonSerializable(typeof(IReadOnlyList<SubagentDelegation>))]
internal sealed partial class SubagentsSerializerContext : JsonSerializerContext;
/// <summary>
/// Factory that builds the supervisor agent + the three sub-agent tools.
/// Mounted in Program.cs at `/subagents`.
/// </summary>
public sealed class SubagentsAgentFactory
{
private const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com";
private const string SubAgentModel = "gpt-4o-mini";
// Each sub-agent is a single-shot ChatClient call (built per-delegation
// in DelegateAsync) with its own system prompt. They don't share memory
// or tools with the supervisor — the supervisor only sees their return
// value as a tool result.
private const string ResearchSystemPrompt =
"You are a research sub-agent. Given a topic, produce a concise " +
"bulleted list of 3-5 key facts. No preamble, no closing.";
private const string WritingSystemPrompt =
"You are a writing sub-agent. Given a brief and optional source facts, " +
"produce a polished 1-paragraph draft. Be clear and concrete. No preamble.";
private const string CritiqueSystemPrompt =
"You are an editorial critique sub-agent. Given a draft, give 2-3 crisp, " +
"actionable critiques. No preamble.";
// @endregion[subagent-setup]
private const string SupervisorPrompt =
"You are a supervisor agent that coordinates three specialized " +
"sub-agents to produce high-quality deliverables.\n\n" +
"Available sub-agents (call them as tools):\n" +
" - research_agent: gathers facts on a topic.\n" +
" - writing_agent: turns facts + a brief into a polished draft.\n" +
" - critique_agent: reviews a draft and suggests improvements.\n\n" +
"For most non-trivial user requests, delegate in sequence: research -> " +
"write -> critique. Pass relevant facts/draft through the `task` argument " +
"of each tool. Each tool returns a JSON object shaped " +
"{status: 'completed' | 'failed', result?: string, error?: string}. " +
"If a sub-agent fails, surface the failure briefly to the user (don't " +
"fabricate a result) and decide whether to retry. Keep your own " +
"messages short — explain the plan once, delegate, then return a " +
"concise summary once done. The UI shows the user a live log of " +
"every sub-agent delegation, including the in-flight 'running' state.";
private readonly OpenAIClient _openAiClient;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly SubagentsStore _store = new();
public SubagentsAgentFactory(
IConfiguration configuration,
ILoggerFactory loggerFactory,
JsonSerializerOptions jsonSerializerOptions)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(jsonSerializerOptions);
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SubagentsAgentFactory>();
_jsonSerializerOptions = jsonSerializerOptions;
var githubToken = configuration["GitHubToken"]
?? throw new InvalidOperationException(
"GitHubToken not found in configuration. " +
"Please set it using: dotnet user-secrets set GitHubToken \"<your-token>\" " +
"or get it using: gh auth token");
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? DefaultOpenAiEndpoint;
_openAiClient = new(
new ApiKeyCredential(githubToken),
AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint));
}
public AIAgent CreateAgent()
{
var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient();
// Each sub-agent is exposed to the supervisor LLM as an AIFunction
// tool. When the supervisor invokes one, DelegateAsync runs a fresh
// ChatClient call with that sub-agent's system prompt, appends a
// Delegation entry to shared state, and returns the sub-agent's
// output to the supervisor as a tool result.
var research = AIFunctionFactory.Create(
(Func<string, CancellationToken, Task<string>>)((task, ct) =>
DelegateAsync("research_agent", ResearchSystemPrompt, task, ct)),
options: new()
{
Name = "research_agent",
Description = "Delegate a research task to the research sub-agent. Returns JSON {status, result?, error?}.",
SerializerOptions = _jsonSerializerOptions,
});
var writing = AIFunctionFactory.Create(
(Func<string, CancellationToken, Task<string>>)((task, ct) =>
DelegateAsync("writing_agent", WritingSystemPrompt, task, ct)),
options: new()
{
Name = "writing_agent",
Description = "Delegate a drafting task to the writing sub-agent. Returns JSON {status, result?, error?}.",
SerializerOptions = _jsonSerializerOptions,
});
var critique = AIFunctionFactory.Create(
(Func<string, CancellationToken, Task<string>>)((task, ct) =>
DelegateAsync("critique_agent", CritiqueSystemPrompt, task, ct)),
options: new()
{
Name = "critique_agent",
Description = "Delegate a critique task to the critique sub-agent. Returns JSON {status, result?, error?}.",
SerializerOptions = _jsonSerializerOptions,
});
// @endregion[supervisor-delegation-tools]
var inner = new ChatClientAgent(
chatClient,
name: "SubagentsSupervisor",
description: SupervisorPrompt,
tools: [research, writing, critique]);
return new SubagentsAgent(inner, _store, _loggerFactory.CreateLogger<SubagentsAgent>());
}
/// <summary>
/// Common delegation flow — append a "running" entry, invoke a single-
/// shot secondary chat-client call, then update the entry to
/// "completed" / "failed". Returns a JSON string the supervisor LLM
/// reads as the tool result, mirroring the dict shape used by the
/// google-adk reference.
/// </summary>
private async Task<string> DelegateAsync(
string subAgent,
string systemPrompt,
string task,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(task);
var entryId = _store.AppendRunning(subAgent, task);
_logger.LogInformation("subagent: starting {SubAgent} (entryId={EntryId}) task={TaskLength} chars", subAgent, entryId, task.Length);
try
{
var secondary = _openAiClient.GetChatClient(SubAgentModel).AsIChatClient();
var messages = new List<ChatMessage>
{
new(ChatRole.System, systemPrompt),
new(ChatRole.User, task),
};
var response = await secondary.GetResponseAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false);
var text = response.Text?.Trim() ?? "";
if (string.IsNullOrEmpty(text))
{
_logger.LogWarning("subagent: {SubAgent} returned no text content", subAgent);
_store.Update(entryId, "failed", "sub-agent returned empty text");
return JsonSerializer.Serialize(new { status = "failed", error = "sub-agent returned empty text" });
}
_store.Update(entryId, "completed", text);
return JsonSerializer.Serialize(new { status = "completed", result = text });
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "subagent: {SubAgent} transport failure", subAgent);
var msg = $"sub-agent call failed: {ex.GetType().Name} (see server logs)";
_store.Update(entryId, "failed", msg);
return JsonSerializer.Serialize(new { status = "failed", error = msg });
}
catch (ClientResultException ex)
{
_logger.LogError(ex, "subagent: {SubAgent} upstream returned status {Status}", subAgent, ex.Status);
var msg = $"sub-agent call failed: upstream returned error status {ex.Status}";
_store.Update(entryId, "failed", msg);
return JsonSerializer.Serialize(new { status = "failed", error = msg });
}
catch (OperationCanceledException)
{
_store.Update(entryId, "failed", "sub-agent call cancelled");
throw;
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,90 @@
using Xunit;
namespace MsAgentDotnet.AgentTests;
// Exercises A2uiSecondaryToolCaller.ParseDesignToolArguments, the response-body
// parser that backs the A2UI secondary tool caller. The parser must:
// * return the tool-call arguments on a well-formed response,
// * return null for the expected "no usable tool call" cases, and
// * throw A2uiUpstreamResponseException (NOT an uncaught KeyNotFoundException
// or JsonException) for a structurally-malformed-but-2xx body, carrying the
// upstream body for server-side logging.
public class A2uiSecondaryToolCallerParseTests
{
private const string DesignTool = "_design_a2ui_surface";
[Fact]
public void WellFormedToolCall_ReturnsArguments()
{
var body = $$"""
{
"choices": [
{ "message": { "tool_calls": [
{ "function": { "name": "{{DesignTool}}", "arguments": "{\"surfaceId\":\"s\"}" } }
] } }
]
}
""";
var result = A2uiSecondaryToolCaller.ParseDesignToolArguments(body);
Assert.Equal("{\"surfaceId\":\"s\"}", result);
}
[Theory]
[InlineData("{\"choices\":[]}")] // empty choices
[InlineData("{\"other\":1}")] // no choices
[InlineData("{\"choices\":[{\"message\":{}}]}")] // no tool_calls
[InlineData("{\"choices\":[{\"message\":{\"tool_calls\":[]}}]}")] // empty tool_calls
public void NoUsableToolCall_ReturnsNull(string body)
{
Assert.Null(A2uiSecondaryToolCaller.ParseDesignToolArguments(body));
}
[Fact]
public void WrongToolName_ReturnsNull()
{
var body = """
{"choices":[{"message":{"tool_calls":[{"function":{"name":"other","arguments":"{}"}}]}}]}
""";
Assert.Null(A2uiSecondaryToolCaller.ParseDesignToolArguments(body));
}
[Fact]
public void NonJsonBody_Throws_WithBodyCaptured()
{
const string body = "not json at all";
var ex = Assert.Throws<A2uiUpstreamResponseException>(
() => A2uiSecondaryToolCaller.ParseDesignToolArguments(body));
Assert.Equal(body, ex.Body);
}
[Fact]
public void ChoiceWithoutMessage_Throws_InsteadOfKeyNotFound()
{
// A choice object that lacks "message" — the bare GetProperty would
// have thrown an uncaught KeyNotFoundException before the guard.
const string body = "{\"choices\":[{\"index\":0}]}";
var ex = Assert.Throws<A2uiUpstreamResponseException>(
() => A2uiSecondaryToolCaller.ParseDesignToolArguments(body));
Assert.Equal(body, ex.Body);
}
[Fact]
public void ToolCallWithoutFunction_Throws_InsteadOfKeyNotFound()
{
// A tool_calls entry that lacks "function" — the bare GetProperty would
// have thrown an uncaught KeyNotFoundException before the guard.
const string body = "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"x\"}]}}]}";
var ex = Assert.Throws<A2uiUpstreamResponseException>(
() => A2uiSecondaryToolCaller.ParseDesignToolArguments(body));
Assert.Equal(body, ex.Body);
}
}
@@ -0,0 +1,143 @@
using System.ClientModel.Primitives;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace MsAgentDotnet.AgentTests;
// Red-green regression test for the .NET header-forwarding root cause.
//
// The bug: AimockHeaderMiddleware set the inbound x-aimock-context header into
// an AsyncLocal, then `await _next(context)` pumped the AG-UI SSE response on an
// ExecutionContext branch that did NOT inherit that AsyncLocal value. So when
// the outbound OpenAI call was made (deep inside the SSE pump), the policy read
// an empty header set -> x-aimock-context was NOT forwarded -> aimock strict
// mode returned 503 -> hung/aborted turns.
//
// The fix: stash the headers on HttpContext.Items and read them via
// IHttpContextAccessor. HttpContext flows across the SSE-pump ExecutionContext
// boundary (the server seeds the accessor's holder at request entry, before any
// middleware, and every branch of the request's async tree shares that holder).
//
// These tests reproduce the ExecutionContext boundary the SSE pump crosses:
// capture an ExecutionContext snapshot at "response start" and run the outbound
// read inside that captured context via ExecutionContext.Run, exactly as the
// SSE pump does. The AsyncLocal-set-after-capture value is invisible there; the
// HttpContext.Items value (read through the accessor singleton) survives.
public class AimockHeaderPropagationTests
{
private const string AimockHeader = "x-aimock-context";
private const string Slug = "gen-ui-chat";
// Demonstrates the ROOT CAUSE: an AsyncLocal set AFTER an ExecutionContext
// snapshot is captured is NOT visible when that snapshot is later run. This
// is precisely the AG-UI SSE-pump boundary the old middleware lost the
// header across. (RED for the old design.)
[Fact]
public void AsyncLocalSetAfterSnapshot_IsInvisibleAcrossExecutionContextBoundary()
{
var asyncLocal = new AsyncLocal<string?>();
// SSE pump captures the ambient ExecutionContext at response-start,
// BEFORE the middleware sets its AsyncLocal for this request.
var capturedAtResponseStart = ExecutionContext.Capture()!;
// Middleware sets the header into the AsyncLocal (old design).
asyncLocal.Value = Slug;
// Outbound LLM call runs inside the captured (pre-set) context.
string? observedAtOutbound = "SENTINEL";
ExecutionContext.Run(capturedAtResponseStart, _ =>
{
observedAtOutbound = asyncLocal.Value;
}, null);
// The header is LOST -> this is the 503-causing bug.
Assert.Null(observedAtOutbound);
}
// Demonstrates the FIX: HttpContext.Items read via IHttpContextAccessor
// survives the same ExecutionContext boundary, because the accessor's holder
// (seeded once, ambient) points at the same mutable HttpContext regardless
// of which captured ExecutionContext snapshot the outbound read runs on.
// (GREEN for the new design.)
[Fact]
public void HttpContextItems_SurvivesExecutionContextBoundary_ViaAccessor()
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
accessor.HttpContext = httpContext;
// SSE pump captures the ambient ExecutionContext at response-start,
// BEFORE the middleware stashes the header on HttpContext.Items.
var capturedAtResponseStart = ExecutionContext.Capture()!;
// Middleware stashes the inbound x-* headers on HttpContext.Items (fix).
var inbound = new Dictionary<string, string> { [AimockHeader] = Slug };
AimockHeaderContext.Set(httpContext, inbound);
// Outbound LLM call runs inside the captured (pre-set) context, reading
// through the accessor exactly as AimockHeaderPolicy.ApplyHeadersAndDiag does.
Dictionary<string, string> observedAtOutbound = new();
ExecutionContext.Run(capturedAtResponseStart, _ =>
{
observedAtOutbound = AimockHeaderContext.Get(accessor.HttpContext);
}, null);
// The header is PRESENT at the outbound boundary -> forwarded -> 200.
Assert.True(observedAtOutbound.ContainsKey(AimockHeader));
Assert.Equal(Slug, observedAtOutbound[AimockHeader]);
}
// End-to-end through the actual policy: the seeded static accessor lets the
// production policy forward x-aimock-context onto a real outbound request
// message, even when the policy runs inside a pre-captured ExecutionContext.
[Fact]
public async Task Policy_ForwardsAimockContext_OntoOutboundRequest_AcrossBoundary()
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
accessor.HttpContext = httpContext;
AimockHeaderPolicy.HttpContextAccessor = accessor;
// SSE pump captures context BEFORE the header is stashed.
var capturedAtResponseStart = ExecutionContext.Capture()!;
AimockHeaderContext.Set(httpContext, new Dictionary<string, string> { [AimockHeader] = Slug });
// Build a real outbound pipeline message and run it through the policy
// inside the captured context (mimicking the SSE-pump outbound call).
var policy = new AimockHeaderPolicy();
var pipeline = ClientPipeline.Create();
using var message = pipeline.CreateMessage();
message.Request.Method = "POST";
message.Request.Uri = new Uri("http://localhost:1/v1/chat/completions");
// The header policy at index 0, followed by a terminal no-op so
// ProcessNext has a successor to hand off to (and we never make a real
// network call).
var policies = new PipelinePolicy[] { policy, new TerminalPolicy() };
var tcs = new TaskCompletionSource();
ExecutionContext.Run(capturedAtResponseStart, _ =>
{
policy.Process(message, policies, 0);
tcs.SetResult();
}, null);
await tcs.Task;
Assert.True(message.Request.Headers.TryGetValue(AimockHeader, out var forwarded));
Assert.Equal(Slug, forwarded);
}
// Terminal pipeline policy: does nothing (does not call ProcessNext), so the
// policy chain stops here without making a real network request.
private sealed class TerminalPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ValueTask.CompletedTask;
}
}
@@ -0,0 +1,184 @@
using System.Text.Json;
using Copilotkit.Showcase.Cvdiag;
using Xunit;
namespace MsAgentDotnet.AgentTests;
// Red-green proof for the L1-F backend CVDIAG instrumentation (spec §3).
//
// RED (before CvdiagBackend existed): the type does not compile / the 11
// boundary emit methods do not exist, so this suite fails to build.
// GREEN: CvdiagBackend, armed via CVDIAG_BACKEND_EMITTER=on, emits each of the
// 11 backend boundaries, and the backend.error.caught path scrubs PII from the
// exception message.
//
// We capture stdout (the emitter writes one single-line JSON per event to
// Console.Out) and assert the boundary set + scrub behavior. The PocketBase
// background write is disabled (no PbWriteUrl) so the test never touches the
// network.
//
// These tests redirect Console.Out to capture the emitter's stdout. xUnit does
// not parallelize tests within a single class, but other test classes could run
// concurrently and write to the shared Console — so the suite is pinned to a
// non-parallel collection to keep the capture deterministic.
[CollectionDefinition("CvdiagStdout", DisableParallelization = true)]
public sealed class CvdiagStdoutCollection { }
[Collection("CvdiagStdout")]
public class CvdiagEmissionTests
{
private static IReadOnlyDictionary<string, string?> ArmedEnv() => new Dictionary<string, string?>
{
["CVDIAG_BACKEND_EMITTER"] = "on",
// DEBUG tier so EVERY backend boundary is included — backend.sse.event is
// debug-tier-only in the §6 tier matrix. DEBUG fail-closes unless the env
// is non-production AND an allow-list is set, so satisfy both here.
["CVDIAG_DEBUG"] = "1",
["SHOWCASE_ENV"] = "development",
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
};
private static CvdiagBackend.RequestContext Ctx() => new()
{
Slug = "gen-ui-chat",
Demo = "default",
TestId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
IngressMs = 1000,
};
private sealed class StdoutCapture : IDisposable
{
private readonly System.IO.TextWriter _original;
private readonly System.IO.StringWriter _buffer = new();
public StdoutCapture()
{
_original = Console.Out;
Console.SetOut(_buffer);
}
public string Text => _buffer.ToString();
public void Dispose() => Console.SetOut(_original);
}
private static List<string> Boundaries(string stdout)
{
var found = new List<string>();
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
try
{
using var doc = JsonDocument.Parse(line);
if (doc.RootElement.TryGetProperty("boundary", out var b))
{
found.Add(b.GetString()!);
}
}
catch
{
// non-JSON noise — ignore
}
}
return found;
}
// (1) All 11 backend boundaries fire for a synthetic agent invocation.
[Fact]
public void AllElevenBackendBoundaries_Fire_WhenArmed()
{
var backend = new CvdiagBackend(ArmedEnv());
Assert.True(backend.IsEnabled);
var ctx = Ctx();
using var cap = new StdoutCapture();
var http = new Microsoft.AspNetCore.Http.DefaultHttpContext();
http.Request.Method = "POST";
http.Request.Path = "/gen-ui-chat";
backend.EmitRequestIngress(ctx, http);
backend.EmitAgentEnter(ctx, "gen-ui-chat", "gpt-4o-mini");
backend.EmitLlmCallStart(ctx, "openai", "gpt-4o-mini", 128);
backend.EmitLlmCallHeartbeat(ctx, 10_000);
backend.EmitLlmCallResponse(ctx, "openai", "gpt-4o-mini", 256, 1500, null);
backend.EmitSseFirstByte(ctx, 350);
backend.EmitSseEvent(ctx, "message", 64, 0);
backend.EmitSseAborted(ctx, "client_disconnect", 1024);
backend.EmitAgentExit(ctx, "ok", 2000);
backend.EmitResponseComplete(ctx, 200, 4096, 2000, 5);
backend.EmitErrorCaught(ctx, new InvalidOperationException("boom"));
var boundaries = Boundaries(cap.Text);
var expected = new[]
{
"backend.request.ingress", "backend.agent.enter", "backend.llm.call.start",
"backend.llm.call.heartbeat", "backend.llm.call.response", "backend.sse.first_byte",
"backend.sse.event", "backend.sse.aborted", "backend.agent.exit",
"backend.response.complete", "backend.error.caught",
};
foreach (var b in expected)
{
Assert.Contains(b, boundaries);
}
Assert.Equal(11, expected.Length);
}
// (2) OFF by default: with no CVDIAG_BACKEND_EMITTER, the layer is a no-op.
[Fact]
public void Disabled_ByDefault_EmitsNothing()
{
var backend = new CvdiagBackend(new Dictionary<string, string?>());
Assert.False(backend.IsEnabled);
using var cap = new StdoutCapture();
var ctx = Ctx();
backend.EmitAgentEnter(ctx, "x", "y");
backend.EmitErrorCaught(ctx, new Exception("nope"));
Assert.Empty(Boundaries(cap.Text));
}
// (3) PII scrub: a secret-shaped token in the exception message is redacted.
[Fact]
public void ErrorCaught_ScrubsPii_FromMessage()
{
var backend = new CvdiagBackend(ArmedEnv());
var ctx = Ctx();
using var cap = new StdoutCapture();
backend.EmitErrorCaught(ctx,
new InvalidOperationException("upstream rejected key sk-test-1234567890 with Bearer abc123def456"));
var line = cap.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.First(l => l.Contains("backend.error.caught"));
Assert.DoesNotContain("sk-test-1234567890", line);
Assert.DoesNotContain("abc123def456", line);
Assert.Contains("[REDACTED]", line);
}
// (3b) Scrub helper directly (unit-level): bearer + sk- keys redacted, cap at 512B.
[Fact]
public void Scrub_RedactsSecrets_AndCaps()
{
Assert.Equal("[REDACTED]", CvdiagBackend.Scrub("sk-abcdefgh12345"));
Assert.DoesNotContain("topsecret", CvdiagBackend.Scrub("Bearer topsecret-token"));
var big = new string('x', 2000);
Assert.True(System.Text.Encoding.UTF8.GetByteCount(CvdiagBackend.Scrub(big)) <= 512);
}
// (3c) URL userinfo: a `scheme://user:pass@host` authority and the colon-less
// `scheme://token@host` form both leak credentials in connection-error text.
// Mirrors scrubSecrets' URL_USERINFO_REGEX (harness/src/cvdiag/scrub.ts).
[Fact]
public void Scrub_RedactsUrlUserinfo_AndBareToken()
{
var withPass = CvdiagBackend.Scrub("connect failed: https://user:pass@example.com/x");
Assert.DoesNotContain("user:pass", withPass);
Assert.DoesNotContain("pass@", withPass);
Assert.Contains("[REDACTED]@", withPass);
Assert.Contains("example.com", withPass); // host preserved
var bareToken = CvdiagBackend.Scrub("connect failed: https://ghp_token@example.com/x");
Assert.DoesNotContain("ghp_token", bareToken);
Assert.Contains("[REDACTED]@", bareToken);
Assert.Contains("example.com", bareToken); // host preserved
}
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- The production project is Microsoft.NET.Sdk.Web and has <Program> as a partial class for WebApplicationFactory-style tests. We only care about SharedStateAgent shape checks, so disable default Web build items leaking in. -->
<RootNamespace>MsAgentDotnet.AgentTests</RootNamespace>
<AssemblyName>SharedStateAgent.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<!-- Needed so the header-propagation tests can use DefaultHttpContext /
HttpContextAccessor (Microsoft.AspNetCore.Http types). The production
project is a Web SDK app, but a project reference does not flow the
ASP.NET shared framework, so reference it explicitly here. -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ProverbsAgent.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,277 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Xunit;
using SSA = SharedStateAgent;
namespace MsAgentDotnet.AgentTests;
/// <summary>
/// Tests covering the SharedStateAgent streaming path. Specifically that the
/// caller-supplied IEnumerable&lt;ChatMessage&gt; is enumerated exactly once,
/// so single-use iterators (e.g. yield-based generators) don't silently yield
/// nothing on a second pass and lose user context on the summary request.
/// </summary>
public class SharedStateAgentStreamingTests
{
// SharedStateAgent's ctor validates that JsonSerializerOptions can resolve
// JsonElement. Attach the reflection-based DefaultJsonTypeInfoResolver so
// the ctor's shape check succeeds (production uses the source-gen context).
private static JsonSerializerOptions CreateSerializerOptions() =>
new(JsonSerializerDefaults.Web)
{
TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(),
};
// Minimal AIAgent fake: records invocations + yields nothing. We don't care
// what the inner agent produces for the enumeration-count test; we just
// need the SharedStateAgent wrapper to call us and have us observe the
// `messages` IEnumerable.
private sealed class RecordingAgent : AIAgent
{
public int RunStreamingCalls { get; private set; }
public List<List<ChatMessage>> CapturedMessageSnapshots { get; } = new();
public override AgentThread GetNewThread() => throw new NotImplementedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
var list = messages.ToList();
CapturedMessageSnapshots.Add(list);
return Task.FromResult(new AgentRunResponse());
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
RunStreamingCalls++;
// Materialize the incoming messages to capture them. (The outer
// SharedStateAgent is expected to have already materialized them
// once — we don't re-enumerate the caller's original iterator.)
var snapshot = messages.ToList();
CapturedMessageSnapshots.Add(snapshot);
await Task.CompletedTask;
yield break;
}
}
// IEnumerable wrapper that records how many times GetEnumerator is called
// so tests can assert the expected enumeration count.
//
// NOTE: this tracker does NOT enforce a single-use contract at the
// iterator level — a second `GetEnumerator()` call still returns a fresh,
// valid enumerator over the same backing array. Tests must explicitly
// `Assert.Equal(1, messages.EnumerationCount)` to catch double-enumeration
// regressions. The name reflects what the type actually does (counts
// enumerations) rather than what it does not do (enforce single use).
private sealed class EnumerationCountingMessages : IEnumerable<ChatMessage>
{
private readonly ChatMessage[] _messages;
private int _enumerations;
public int EnumerationCount => _enumerations;
public EnumerationCountingMessages(params ChatMessage[] messages)
{
_messages = messages;
}
public IEnumerator<ChatMessage> GetEnumerator()
{
_enumerations++;
return ((IEnumerable<ChatMessage>)_messages).GetEnumerator();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public async Task RunStreamingAsync_NoAgUiState_EnumeratesCallerMessagesOnce()
{
// When there's no AG-UI state attached, the non-structured path runs.
// Even here a naive implementation could accidentally enumerate twice
// (e.g. for logging). Verify we only enumerate the caller's iterator
// a single time — we then pass the materialized list to the inner
// agent.
var inner = new RecordingAgent();
var agent = new SSA(inner, CreateSerializerOptions());
var messages = new EnumerationCountingMessages(new ChatMessage(ChatRole.User, "hi"));
await foreach (var _ in agent.RunStreamingAsync(messages).ConfigureAwait(false))
{
// drain
}
Assert.Equal(1, messages.EnumerationCount);
Assert.Equal(1, inner.RunStreamingCalls);
}
[Fact]
public async Task RunStreamingAsync_WithSalesState_EnumeratesCallerMessagesOnce()
{
// With sales state attached, SharedStateAgent runs the two-pass
// structured-output flow: firstRunMessages and secondRunMessages both
// need the caller's message list. A previous bug enumerated `messages`
// for each of those appends — a yield-based generator would silently
// yield nothing on the second pass, and the summary request would run
// without user context.
var inner = new RecordingAgent();
var agent = new SSA(inner, CreateSerializerOptions());
var messages = new EnumerationCountingMessages(new ChatMessage(ChatRole.User, "update my pipeline"));
// Attach sales-shaped ag_ui_state so the structured-output path runs.
var statePayload = JsonDocument.Parse("{\"todos\":[{\"id\":\"a\",\"title\":\"Deal 1\"}]}").RootElement;
var chatOptions = new ChatOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["ag_ui_state"] = statePayload,
},
};
var options = new ChatClientAgentRunOptions { ChatOptions = chatOptions };
await foreach (var _ in agent.RunStreamingAsync(messages, options: options).ConfigureAwait(false))
{
// drain
}
// The critical assertion: caller's iterator was enumerated exactly
// once, regardless of how many times SharedStateAgent needs to compose
// derived message lists internally.
Assert.Equal(1, messages.EnumerationCount);
}
// Inner agent that emits a controllable sequence of text-only updates on
// the first RunStreamingAsync call and nothing on subsequent calls. Used
// to exercise the buffered-text cap and the deserialize-success paths.
private sealed class TextEmittingAgent : AIAgent
{
private readonly IReadOnlyList<string> _firstPassChunks;
private readonly string? _secondPassChunk;
private int _callCount;
public TextEmittingAgent(IReadOnlyList<string> firstPassChunks, string? secondPassChunk = null)
{
_firstPassChunks = firstPassChunks;
_secondPassChunk = secondPassChunk;
}
public override AgentThread GetNewThread() => throw new NotImplementedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRunResponse());
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var call = Interlocked.Increment(ref _callCount);
var chunks = call == 1 ? _firstPassChunks : (_secondPassChunk is null ? Array.Empty<string>() : new[] { _secondPassChunk });
foreach (var chunk in chunks)
{
yield return new AgentRunResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)],
};
}
await Task.CompletedTask;
}
}
[Fact]
public async Task RunStreamingAsync_BufferedTextCap_DropsUpdatesBeyondCap()
{
// Pathological first-pass response: emit many text chunks whose total
// length exceeds MaxBufferedTextChars. The deserialize will fail
// (plain text is not valid JSON for SalesStateSnapshot), so we fall
// back to replaying the buffered text updates. The total replayed
// character count must NOT exceed the cap plus the largest single
// chunk size — anything beyond that signals the cap isn't enforced.
const int chunkSize = 10_000;
const int chunkCount = 150; // 1.5 MB total — exceeds the 1 MB cap.
var chunks = Enumerable.Range(0, chunkCount).Select(_ => new string('x', chunkSize)).ToArray();
var inner = new TextEmittingAgent(chunks);
var agent = new SSA(inner, CreateSerializerOptions());
var statePayload = JsonDocument.Parse("{\"todos\":[{\"id\":\"a\",\"title\":\"Deal 1\"}]}").RootElement;
var chatOptions = new ChatOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["ag_ui_state"] = statePayload,
},
};
var options = new ChatClientAgentRunOptions { ChatOptions = chatOptions };
var totalReplayedChars = 0;
await foreach (var update in agent.RunStreamingAsync(new[] { new ChatMessage(ChatRole.User, "hi") }, options: options))
{
foreach (var c in update.Contents.OfType<TextContent>())
{
totalReplayedChars += c.Text?.Length ?? 0;
}
}
// Replay must be bounded strictly by the cap. The buffering admission
// check is pre-increment: a chunk that WOULD cross the cap is rejected
// in full (no partial admission), and already-admitted chunks are
// kept. So the replayed total is always <= MaxBufferedTextChars — no
// slack. If enforcement ever becomes post-increment (admit first, then
// check), the bound would loosen to <= MaxBufferedTextChars + chunkSize
// and this assertion would need to relax accordingly.
Assert.True(
totalReplayedChars <= SSA.MaxBufferedTextChars,
$"Replayed {totalReplayedChars} chars; cap is {SSA.MaxBufferedTextChars}.");
}
[Fact]
public async Task RunStreamingAsync_DeserializeSuccess_NoSalesData_SkipsDataContentEmit()
{
// First-pass produces a syntactically valid SalesStateSnapshot with
// an empty todos array. TryDeserialize succeeds, but
// ShouldEmitStateSnapshot returns false because todos is empty, so no
// DataContent is emitted. The second pass (for the summary) is
// allowed to run; we assert the absence of DataContent across the
// full output rather than counting exact updates.
var firstPass = new[] { "{\"todos\":[]}" };
var inner = new TextEmittingAgent(firstPass, secondPassChunk: "ok");
var agent = new SSA(inner, CreateSerializerOptions());
var statePayload = JsonDocument.Parse("{\"todos\":[{\"id\":\"a\",\"title\":\"Deal 1\"}]}").RootElement;
var chatOptions = new ChatOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["ag_ui_state"] = statePayload,
},
};
var options = new ChatClientAgentRunOptions { ChatOptions = chatOptions };
var hasDataContent = false;
await foreach (var update in agent.RunStreamingAsync(new[] { new ChatMessage(ChatRole.User, "hi") }, options: options))
{
if (update.Contents.Any(c => c is DataContent))
{
hasDataContent = true;
}
}
Assert.False(hasDataContent, "Empty-todos snapshot must not be emitted as DataContent.");
}
}
@@ -0,0 +1,65 @@
using System.Text.Json;
using Xunit;
// Alias the production type (declared in the default/global namespace) so the
// test file doesn't need to repeat `global::SharedStateAgent` at every call
// site. Access is via InternalsVisibleTo on the production csproj.
using SSA = SharedStateAgent;
namespace MsAgentDotnet.AgentTests;
public class StateContainsSalesDataTests
{
private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement;
public static IEnumerable<object[]> Cases()
{
// Case 1: state is a JSON null value (ValueKind == Null)
yield return new object[] { "JsonNullValue", Parse("null"), false };
// Case 2: state is an array (not an object)
yield return new object[] { "TopLevelArray", Parse("[1,2,3]"), false };
// Case 3: state is an object but todos is missing
yield return new object[] { "TodosMissing", Parse("{\"other\":1}"), false };
// Case 4: todos is an empty array
yield return new object[] { "TodosEmptyArray", Parse("{\"todos\":[]}"), false };
// Case 5: todos is a non-array value (an object)
yield return new object[] { "TodosAsObject", Parse("{\"todos\":{}}"), false };
// Case 6: todos is a non-array primitive (a string)
yield return new object[] { "TodosAsString", Parse("{\"todos\":\"nope\"}"), false };
// Case 7: todos is an array with non-object elements
yield return new object[] { "TodosArrayOfPrimitives", Parse("{\"todos\":[1,2,3]}"), false };
// Case 8: todos is populated with object elements
yield return new object[]
{
"TodosPopulatedWithObjects",
Parse("{\"todos\":[{\"id\":\"a\",\"title\":\"T1\",\"stage\":\"prospect\"}]}"),
true,
};
}
[Theory]
[MemberData(nameof(Cases))]
public void StateContainsSalesData_ReturnsExpected(string label, JsonElement state, bool expected)
{
// label is included for test-runner readability only; silence unused warnings.
_ = label;
var actual = SSA.StateContainsSalesData(state);
Assert.Equal(expected, actual);
}
[Fact]
public void StateContainsSalesData_MissingState_UndefinedValueKind_ReturnsFalse()
{
// JsonElement default (ValueKind == Undefined) simulates "no state attached".
var undefined = default(JsonElement);
Assert.Equal(JsonValueKind.Undefined, undefined.ValueKind);
Assert.False(SSA.StateContainsSalesData(undefined));
}
}
@@ -0,0 +1,448 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace MsAgentDotnet.AgentTests;
/// <summary>
/// Shape and serialization tests for <see cref="SalesTodo"/>, <see cref="SalesState"/>,
/// <see cref="SalesStateSnapshot"/>, and <see cref="FlightInfo"/>. These types replaced
/// a primitive-obsession implementation (free-form string stages, int money values,
/// parallel <c>Status</c> + <c>StatusColor</c> strings) with typed enums, decimals,
/// and derived properties; the tests pin the wire format so downstream clients
/// don't silently break.
/// </summary>
public class SalesTodoShapeTests
{
private static JsonSerializerOptions NewOptions()
{
var opts = new JsonSerializerOptions();
opts.Converters.Add(new JsonStringEnumConverter());
return opts;
}
[Fact]
public void SalesTodo_Stage_SerializesAsEnumMemberName()
{
// Stage is a typed SalesStage enum; it serializes as the enum member
// name so both callers and the model see a closed set of legal values.
var todo = new SalesTodo { Id = "t1", Stage = SalesStage.Qualified, Value = 1000m };
var json = JsonSerializer.Serialize(todo, NewOptions());
Assert.Contains("\"stage\":\"Qualified\"", json);
}
[Fact]
public void SalesTodo_Value_SerializesAsDecimalNumber()
{
// Value is decimal (money). Verify it round-trips as a JSON number
// (not a quoted string) and preserves fractional precision.
var todo = new SalesTodo { Id = "t2", Value = 1234.56m };
var json = JsonSerializer.Serialize(todo, NewOptions());
var doc = JsonDocument.Parse(json).RootElement;
Assert.Equal(JsonValueKind.Number, doc.GetProperty("value").ValueKind);
Assert.Equal(1234.56m, doc.GetProperty("value").GetDecimal());
}
[Fact]
public void SalesTodo_Value_Negative_Throws()
{
// Value is money; negative values are not a legal business state.
// The init accessor rejects them with ArgumentOutOfRangeException.
Assert.Throws<ArgumentOutOfRangeException>(() =>
new SalesTodo { Id = "neg", Value = -1000m });
}
[Fact]
public void SalesTodo_Value_Zero_Allowed()
{
// Zero is a legal amount (e.g. an unpriced prospect).
var todo = new SalesTodo { Id = "zero", Value = 0m };
Assert.Equal(0m, todo.Value);
}
[Fact]
public void SalesTodo_DueDate_SerializesAsIsoDate()
{
// DueDate is a nullable DateOnly; System.Text.Json emits DateOnly as
// the ISO-8601 YYYY-MM-DD form.
var todo = new SalesTodo { Id = "t3", DueDate = new DateOnly(2026, 4, 20) };
var json = JsonSerializer.Serialize(todo, NewOptions());
Assert.Contains("\"dueDate\":\"2026-04-20\"", json);
}
[Fact]
public void SalesTodo_DueDate_NullIsSerializedAsJsonNull()
{
var todo = new SalesTodo { Id = "t4", DueDate = null };
var json = JsonSerializer.Serialize(todo, NewOptions());
Assert.Contains("\"dueDate\":null", json);
}
[Fact]
public void SalesTodo_RequiredId_CompilesAndRoundTrips()
{
// The `required` keyword forces callers to explicitly provide an Id.
// This test is mostly a compile-time anchor; at runtime we just
// confirm the value we set round-trips unchanged.
var todo = new SalesTodo { Id = "explicit-id" };
var json = JsonSerializer.Serialize(todo, NewOptions());
var round = JsonSerializer.Deserialize<SalesTodo>(json, NewOptions());
Assert.NotNull(round);
Assert.Equal("explicit-id", round!.Id);
}
[Fact]
public void SalesTodo_NewPending_AssignsNonEmptyGuidId()
{
// NewPending is the explicit factory for "server should assign an
// id". It generates a 16-hex-char id so callers never have to know
// about the empty-string sentinel that ReplaceTodos backfills.
var todo = SalesTodo.NewPending(title: "deal");
Assert.False(string.IsNullOrEmpty(todo.Id));
Assert.Equal(16, todo.Id.Length);
Assert.Equal("deal", todo.Title);
}
[Fact]
public void SalesTodo_Currency_SerializesAsEnumMemberName()
{
var todo = new SalesTodo { Id = "t5", Currency = Currency.EUR };
var json = JsonSerializer.Serialize(todo, NewOptions());
Assert.Contains("\"currency\":\"EUR\"", json);
}
[Theory]
[InlineData(SalesStage.Prospect, false)]
[InlineData(SalesStage.Qualified, false)]
[InlineData(SalesStage.Proposal, false)]
[InlineData(SalesStage.Negotiation, false)]
[InlineData(SalesStage.ClosedWon, true)]
[InlineData(SalesStage.ClosedLost, true)]
public void SalesTodo_Completed_IsDerivedFromStage(SalesStage stage, bool expectedCompleted)
{
// Completed used to be an independent bool that could contradict
// Stage (e.g. ClosedWon + Completed=false). It's now a computed
// property so the pair cannot disagree.
var todo = new SalesTodo { Id = "d", Stage = stage };
Assert.Equal(expectedCompleted, todo.Completed);
}
}
public class SalesStateEncapsulationTests
{
[Fact]
public void SalesState_Todos_IsReadOnly_AtCompileTime()
{
// The Todos property is IReadOnlyList<SalesTodo> with no public
// setter. Verified via reflection so a regression (adding a setter
// or reverting to a mutable List<>) breaks the build-time contract.
var prop = typeof(SalesState).GetProperty(nameof(SalesState.Todos));
Assert.NotNull(prop);
Assert.Equal(typeof(IReadOnlyList<SalesTodo>), prop!.PropertyType);
Assert.Null(prop.SetMethod);
}
[Fact]
public void SalesState_ReplaceTodos_BackfillsEmptyIds()
{
// ReplaceTodos is the single writer for SalesState.Todos. Empty-id
// todos (the documented sentinel for "server assigns") are backfilled
// with a freshly generated Guid-derived id; non-empty ids are kept.
var state = new SalesState();
state.ReplaceTodos(new[]
{
new SalesTodo { Id = "", Title = "A" },
new SalesTodo { Id = "keep-me", Title = "B" },
});
Assert.Equal(2, state.Todos.Count);
Assert.False(string.IsNullOrEmpty(state.Todos[0].Id));
Assert.NotEqual("", state.Todos[0].Id);
// 16 hex chars = 64 bits of entropy — safe for demo scale.
Assert.Equal(16, state.Todos[0].Id.Length);
Assert.Equal("keep-me", state.Todos[1].Id);
}
[Fact]
public void SalesState_ReplaceTodos_PublishedListIsReadOnly()
{
// The published list must be IReadOnlyList and must NOT alias the
// caller's input collection, so external mutation after publish
// cannot leak into the state.
var state = new SalesState();
var source = new List<SalesTodo>
{
new() { Id = "x", Title = "X" },
};
state.ReplaceTodos(source);
source.Clear();
Assert.Single(state.Todos);
}
[Fact]
public void SalesStateSnapshot_IsRecordWrappingReadOnlyList()
{
// SalesStateSnapshot was previously a near-duplicate mutable class.
// It is now an immutable record wrapping IReadOnlyList<SalesTodo>.
var snapshot = new SalesStateSnapshot(new[]
{
new SalesTodo { Id = "a", Title = "A" },
});
Assert.Single(snapshot.Todos);
Assert.Equal("a", snapshot.Todos[0].Id);
// Records provide value-based equality.
var same = snapshot with { };
Assert.Equal(snapshot, same);
}
[Fact]
public void SalesStateSnapshot_SerializesTodosKey()
{
// The wire-format key is lowercase "todos" via JsonPropertyName,
// even without a camelCase naming policy.
var snapshot = new SalesStateSnapshot(Array.Empty<SalesTodo>());
var opts = new JsonSerializerOptions();
opts.Converters.Add(new JsonStringEnumConverter());
var json = JsonSerializer.Serialize(snapshot, opts);
Assert.Contains("\"todos\":", json);
}
}
public class FlightInfoShapeTests
{
private static JsonSerializerOptions NewOptions()
{
var opts = new JsonSerializerOptions();
opts.Converters.Add(new JsonStringEnumConverter());
return opts;
}
[Fact]
public void FlightInfo_StatusColor_DerivedFromStatus_OnTimeGreen()
{
var flight = new FlightInfo { Status = FlightStatus.OnTime };
Assert.Equal("green", flight.StatusColor);
}
[Theory]
[InlineData(FlightStatus.OnTime, "green")]
[InlineData(FlightStatus.Delayed, "yellow")]
[InlineData(FlightStatus.Cancelled, "red")]
[InlineData(FlightStatus.Boarding, "blue")]
public void FlightInfo_StatusColor_MatchesStatus(FlightStatus status, string expectedColor)
{
// StatusColor is derived from Status — the pair cannot disagree.
var flight = new FlightInfo { Status = status };
Assert.Equal(expectedColor, flight.StatusColor);
}
[Fact]
public void FlightInfo_Price_IsDecimal_Currency_IsEnum()
{
// Price is a decimal (money) + Currency is a typed enum, replacing
// the old "$342" + "USD" pair.
var flight = new FlightInfo { Price = 342.00m, Currency = Currency.USD };
var opts = NewOptions();
var json = JsonSerializer.Serialize(flight, opts);
var doc = JsonDocument.Parse(json).RootElement;
Assert.Equal(JsonValueKind.Number, doc.GetProperty("price").ValueKind);
Assert.Equal("USD", doc.GetProperty("currency").GetString());
}
}
/// <summary>
/// Exercises the <see cref="SalesAgentFactory.BuildA2uiResponseFromContent"/>
/// helper — the content-processing stage of GenerateA2ui, which is the only
/// part reachable without a live OpenAI client. Each error branch
/// (JsonException, shape mismatch, malformed components, shape deserialize)
/// is covered with a controlled input string so regressions that leak raw
/// ex.Message or swallow errors silently fail loudly.
/// </summary>
public class GenerateA2uiErrorBranchTests
{
private const string ErrorId = "test1234";
private static Microsoft.Extensions.Logging.ILogger NewLogger()
=> NullLogger.Instance;
private static JsonElement ParseToElement(object payload)
{
var json = payload is string value ? value : JsonSerializer.Serialize(payload);
return JsonDocument.Parse(json).RootElement;
}
[Fact]
public void MalformedJson_ReturnsStructuredError_Category_MalformedLlmOutput()
{
// Input isn't valid JSON at all — JsonDocument.Parse throws
// JsonException on the very first try. Error category and errorId
// must be present; no raw exception message.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"This is not JSON at all",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("malformed_llm_output", doc.GetProperty("error").GetString());
Assert.Equal(ErrorId, doc.GetProperty("errorId").GetString());
Assert.False(string.IsNullOrEmpty(doc.GetProperty("message").GetString()));
Assert.False(string.IsNullOrEmpty(doc.GetProperty("remediation").GetString()));
}
[Fact]
public void JsonButNotObject_ReturnsStructuredError()
{
// JSON parses, but the root is an array, not the expected object.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"[1, 2, 3]",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("malformed_llm_output", doc.GetProperty("error").GetString());
Assert.Equal(ErrorId, doc.GetProperty("errorId").GetString());
}
[Fact]
public void MissingComponentsArray_ReturnsStructuredError()
{
// Object is otherwise valid but 'components' is missing entirely.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"{\"surfaceId\":\"s1\"}",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("malformed_llm_output", doc.GetProperty("error").GetString());
}
[Fact]
public void ComponentsNotArray_ReturnsStructuredError()
{
// 'components' exists but is a string rather than an array.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"{\"components\":\"not-an-array\"}",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("malformed_llm_output", doc.GetProperty("error").GetString());
}
[Fact]
public void WellFormedInput_ReturnsOperationsPayload()
{
// Happy path: valid shape produces the a2ui_operations envelope with
// the nested A2UI v0.9 operations expected by @ag-ui/a2ui-middleware:
// createSurface, updateComponents, and (when data present) updateDataModel.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"{\"surfaceId\":\"ds\",\"catalogId\":\"copilotkit://c\",\"components\":[{\"id\":\"root\",\"component\":\"Row\"}],\"data\":{\"k\":1}}",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
var ops = doc.GetProperty("a2ui_operations");
Assert.Equal(JsonValueKind.Array, ops.ValueKind);
Assert.Equal(3, ops.GetArrayLength());
Assert.Equal("v0.9", ops[0].GetProperty("version").GetString());
Assert.Equal("ds", ops[0].GetProperty("createSurface").GetProperty("surfaceId").GetString());
Assert.Equal("copilotkit://c", ops[0].GetProperty("createSurface").GetProperty("catalogId").GetString());
Assert.Equal("ds", ops[1].GetProperty("updateComponents").GetProperty("surfaceId").GetString());
Assert.Equal(JsonValueKind.Array, ops[1].GetProperty("updateComponents").GetProperty("components").ValueKind);
Assert.Equal("ds", ops[2].GetProperty("updateDataModel").GetProperty("surfaceId").GetString());
Assert.Equal("/", ops[2].GetProperty("updateDataModel").GetProperty("path").GetString());
Assert.Equal(1, ops[2].GetProperty("updateDataModel").GetProperty("value").GetProperty("k").GetInt32());
}
[Fact]
public void WellFormedInput_NoData_OmitsUpdateDataModel()
{
// Data is optional; when absent we emit only createSurface and
// updateComponents.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
"{\"components\":[{\"id\":\"root\",\"component\":\"Row\"}]}",
ErrorId,
NewLogger());
var doc = ParseToElement(output);
var ops = doc.GetProperty("a2ui_operations");
Assert.Equal(2, ops.GetArrayLength());
}
[Fact]
public void StructuredError_HasExactlyTheContractKeys()
{
// Pin the wire contract: clients and the LLM both rely on the four
// keys (error, message, remediation, errorId). A regression to raw
// ex.Message would drop remediation/errorId and this test would fail.
var output = SalesAgentFactory.StructuredError("cat", "msg", "rem", "eid");
var doc = ParseToElement(output);
var keys = new HashSet<string>();
foreach (var prop in doc.EnumerateObject())
{
keys.Add(prop.Name);
}
Assert.Equal(
new HashSet<string> { "error", "message", "remediation", "errorId" },
keys);
}
[Fact]
public void NullContent_ReturnsStructuredError_EmptyLlmOutput()
{
// result.Text from the upstream chat client can legitimately be null
// (content filter tripped, empty completion, model refusal). The old
// code propagated that straight into BuildA2uiResponseFromContent,
// which did ArgumentNullException.ThrowIfNull(content) — escaping as
// an uncaught NRE that broke the structured-error contract. Now the
// helper returns a structured empty_llm_output error instead.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
null,
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("empty_llm_output", doc.GetProperty("error").GetString());
Assert.Equal(ErrorId, doc.GetProperty("errorId").GetString());
Assert.False(string.IsNullOrEmpty(doc.GetProperty("message").GetString()));
Assert.False(string.IsNullOrEmpty(doc.GetProperty("remediation").GetString()));
}
[Fact]
public void EmptyContent_ReturnsStructuredError_EmptyLlmOutput()
{
// Empty string is treated the same as null: the upstream produced no
// usable text content. JsonDocument.Parse("") would throw JsonException
// and be reported as malformed_llm_output, which is less accurate — an
// empty string isn't malformed, it's absent. Guard it up-front.
var output = SalesAgentFactory.BuildA2uiResponseFromContent(
string.Empty,
ErrorId,
NewLogger());
var doc = ParseToElement(output);
Assert.Equal("empty_llm_output", doc.GetProperty("error").GetString());
Assert.Equal(ErrorId, doc.GetProperty("errorId").GetString());
}
}