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,21 @@
**/node_modules
.next
__pycache__
*.pyc
.git
**/vitest.config.ts
**/test-setup.ts
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# .NET build outputs. Mirrors .gitignore — Docker uses .dockerignore
# exclusively and without these entries a stale local `dotnet build`
# (under agent/bin, agent/obj, agent/tests/bin, etc.) gets shipped into
# the build context on every image build. Wasted bytes and occasionally
# a stale-artifact cache hit that masks real breakage.
**/bin
**/obj
agent/tests
*.user
*.suo
@@ -0,0 +1,9 @@
# GitHub token for accessing GitHub Models (Azure AI inference endpoint)
# Get via: gh auth token
GitHubToken=ghp_...
# Agent backend URL (for the CopilotKit runtime proxy)
AGENT_URL=http://localhost:8000
# Showcase
NEXT_PUBLIC_BASE_URL=http://localhost:3000
@@ -0,0 +1,19 @@
node_modules/
.next/
.env.local
.env
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
# Shared modules (copied by CI)
shared_frontend/
# .NET build outputs
bin/
obj/
*.user
*.suo
@@ -0,0 +1,71 @@
# Stage 1: Build Next.js frontend
FROM node:22-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
# Stage 2: Build .NET agent
FROM mcr.microsoft.com/dotnet/sdk:9.0.312 AS agent-build
WORKDIR /agent
COPY agent/ ./
# CVDIAG shared binding (plan unit L1-F): the single-source emitter lives at
# showcase/integrations/_shared/dotnet/ and is source-included by the csproj via
# ..\_shared\dotnet\*.cs. The `_shared` symlink at this integration's root is
# materialized to a real dir by the harness stage_shared step, so it is present
# in the build context here. Land it at /_shared/dotnet so the csproj at /agent
# resolves ..\_shared\dotnet (= /_shared/dotnet) identically to the local layout.
COPY _shared/dotnet/*.cs /_shared/dotnet/
# Explicit project path: the tests/ subdirectory contains a separate test csproj
# that must not be picked up by the default glob when publishing the web agent.
RUN dotnet publish ProverbsAgent.csproj -c Release -o /agent/publish
# Stage 3: Production image — aspnet runtime + Node.js (no SDK, no build tools)
FROM mcr.microsoft.com/dotnet/aspnet:9.0.14 AS runner
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
# by name and so recursive chown over /app is never needed (fast builds).
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
&& mkdir -p /home/app && chown app:app /home/app
# Next.js build artifacts (from frontend stage — no npm install at runtime)
COPY --chown=app:app --from=frontend /app/.next ./.next
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
COPY --chown=app:app --from=frontend /app/package.json ./
COPY --chown=app:app --from=frontend /app/public ./public
# .NET agent binary (from agent-build stage — no dotnet SDK at runtime)
COPY --chown=app:app --from=agent-build /agent/publish /agent
# Entrypoint
COPY --chown=app:app entrypoint.sh ./
RUN chmod +x entrypoint.sh
# Ensure WORKDIR itself is owned by `app` — `WORKDIR /app` at the top of the
# stage creates /app as root, and `COPY --chown=app:app` only reassigns the
# copied files, NOT the parent dir. Without this, any subprocess that tries
# to mkdir under /app at runtime (Next.js build caches, .NET tooling
# caches, etc.) hits EACCES under the unprivileged user and crashes the
# container.
RUN chown app:app /app
# Ensure /agent is traversable by the app user (dotnet reads from here).
RUN chown -R app:app /agent
USER app
EXPOSE 10000
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
# entrypoint.sh scopes NODE_ENV=production to the Next.js invocation only
# so non-Next children (dotnet agent, shell, healthchecks) see the host's
# environment.
ENV PORT=10000
ENV HOSTNAME=0.0.0.0
CMD ["./entrypoint.sh"]
+1
View File
@@ -0,0 +1 @@
../_shared
@@ -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());
}
}
@@ -0,0 +1,41 @@
{
"framework": "ms-agent-dotnet",
"features": {
"agentic-chat": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/quickstart",
"shell_docs_path": "/quickstart"
},
"hitl-in-chat": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/human-in-the-loop",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
"tool-rendering": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
"gen-ui-tool-based": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/generative-ui/your-components/display-only",
"shell_docs_path": "/generative-ui/your-components/display-only"
},
"gen-ui-agent": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
"shared-state-read-write": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/shared-state/in-app-agent-write",
"shell_docs_path": "/shared-state"
},
"shared-state-streaming": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/shared-state/predictive-state-updates",
"shell_docs_path": "/shared-state"
},
"subagents": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework",
"shell_docs_path": "/multi-agent/subagents"
},
"auth": {
"og_docs_url": "https://docs.copilotkit.ai/microsoft-agent-framework/auth",
"shell_docs_path": "/auth"
}
}
}
@@ -0,0 +1,85 @@
#!/bin/bash
set -e
cleanup() {
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
}
trap cleanup EXIT
echo "========================================="
echo "[entrypoint] Starting showcase package: ms-agent-dotnet"
echo "[entrypoint] Time: $(date -u)"
echo "[entrypoint] PORT=${PORT:-not set}"
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
echo "========================================="
if [ -z "$AZURE_OPENAI_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then
echo "[entrypoint] WARNING: Neither AZURE_OPENAI_API_KEY nor OPENAI_API_KEY is set! Agent will fail."
fi
# Start .NET agent backend on :8000 with log prefixing so its output is
# distinguishable from Next.js in the Railway log stream.
# `awk ... fflush()` line-flushes each prefixed line to the container log.
echo "[entrypoint] Starting .NET agent on port 8000..."
dotnet /agent/ProverbsAgent.dll --urls "http://0.0.0.0:8000" &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
sleep 3
if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: Agent failed to start — exiting"
exit 1
fi
echo "========================================="
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
echo "========================================="
PORT=${PORT:-10000}
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXTJS_PID=$!
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
# Watchdog: Railway deploys of showcase packages have been observed to hit a
# silent agent hang — the agent process stays alive (so `wait -n` never
# fires and the container never restarts) but stops responding on :8000.
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
# (~90s of unreachable agent), kill the agent process so `wait -n` returns
# and Railway restarts the container. Generalized from
# showcase/integrations/crewai-crews/entrypoint.sh (PRs #4114 + #4115).
(
FAILS=0
while sleep 30; do
if ! kill -0 $AGENT_PID 2>/dev/null; then
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
FAILS=0
else
FAILS=$((FAILS + 1))
echo "[watchdog] Agent health probe failed (count=$FAILS)"
if [ $FAILS -ge 3 ]; then
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
kill -9 $AGENT_PID 2>/dev/null || true
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)"
echo "[entrypoint] All processes running. Waiting..."
wait -n $AGENT_PID $NEXTJS_PID
EXIT_CODE=$?
if ! kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
else
echo "[entrypoint] A process exited with code $EXIT_CODE"
fi
exit $EXIT_CODE
@@ -0,0 +1,540 @@
name: MS Agent Framework (.NET)
slug: ms-agent-dotnet
category: enterprise-platform
language: dotnet
logo: /logos/ms-agent-dotnet.svg
description: >-
CopilotKit integration with Microsoft Agent Framework for .NET via AG-UI
protocol. Full feature parity including generative UI, shared state,
human-in-the-loop, sub-agents, and streaming.
partner_docs: https://docs.copilotkit.ai/microsoft-agent-framework
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/ms-agent-dotnet
copilotkit_version: 2.0.0
deployed: true
docs_mode: authored
sort_order: 160
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
interaction_modalities:
- sidebar
- embedded
- chat
not_supported_features:
- shared-state-streaming
# QUARANTINED pending a @copilotkit/react-core release. gen-ui-interrupt /
# interrupt-headless fail turn-2 on a useInterrupt/useHeadlessInterrupt
# RESUME-PATH hook bug in @copilotkit/react-core/v2: backend resumes + streams
# (HTTP 200) but the frontend never appends the confirmation assistant bubble,
# so the harness DOM settle-check times out. Fix is a published-package change
# (out of scope here); honestly marked not-supported (skipped-incapable, not
# green, not red) rather than a regression. Demos remain wired below.
- gen-ui-interrupt
- interrupt-headless
features:
- beautiful-chat
- cli-start
- agentic-chat
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- chat-customization-css
- headless-simple
- headless-complete
- frontend-tools
- frontend-tools-async
- hitl
- hitl-in-app
- hitl-in-chat
- gen-ui-tool-based
- declarative-gen-ui
- a2ui-fixed-schema
- mcp-apps
- gen-ui-agent
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- tool-rendering
- tool-rendering-reasoning-chain
- shared-state-read-write
- subagents
- readonly-state-agent-context
- multimodal
- auth
- reasoning-custom
- reasoning-default
- declarative-hashbrown
- declarative-json-render
- open-gen-ui
- open-gen-ui-advanced
- voice
- agent-config
- shared-state-read
a2ui_pattern: schema-inline
interrupt_pattern: promise-based
agent_config_pattern: shared-state
auth_pattern: microsoft-agent-framework
demos:
- id: cli-start
name: CLI Start Command
description: Copy-paste command to clone the canonical starter
tags:
- chat-ui
command: "npx copilotkit@latest init --framework ms-agent-dotnet"
- id: agentic-chat
name: Agentic Chat
description: Natural conversation with frontend tool execution
tags:
- chat-ui
route: /demos/agentic-chat
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/agentic-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-customization-css
name: Chat Customization (CSS)
description: Default CopilotChat re-themed via CopilotKitCSSProperties
tags:
- chat-ui
route: /demos/chat-customization-css
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/chat-customization-css/page.tsx
- src/app/demos/chat-customization-css/theme.css
- src/app/api/copilotkit/route.ts
- id: tool-rendering-default-catchall
name: Tool Rendering (Default Catch-all)
description: Out-of-the-box tool rendering — backend defines the tools; the frontend adds zero custom renderers and relies on CopilotKit's built-in default UI
tags:
- generative-ui
route: /demos/tool-rendering-default-catchall
animated_preview_url:
highlight:
- src/app/demos/tool-rendering-default-catchall/page.tsx
- agent/Program.cs
- src/app/api/copilotkit/route.ts
- id: tool-rendering-custom-catchall
name: Tool Rendering (Custom Catch-all)
description: Single branded wildcard renderer via useDefaultRenderTool — the same app-designed card paints every tool call
tags:
- generative-ui
route: /demos/tool-rendering-custom-catchall
animated_preview_url:
highlight:
- src/app/demos/tool-rendering-custom-catchall/page.tsx
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
- agent/Program.cs
- src/app/api/copilotkit/route.ts
- id: frontend-tools
name: Frontend Tools (In-App Actions)
description: Agent invokes client-side handlers registered with useFrontendTool
tags:
- interactivity
route: /demos/frontend-tools
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/frontend-tools/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools-async
name: Frontend Tools (Async)
description: useFrontendTool with an async handler — agent awaits a client-side async operation (simulated notes DB query) and uses the returned result
tags:
- interactivity
route: /demos/frontend-tools-async
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/frontend-tools-async/page.tsx
- src/app/demos/frontend-tools-async/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat Human in the Loop (Original)
description: User approves agent actions before execution
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
- id: hitl-in-app
name: In-App Human in the Loop (Frontend Tools + async HITL)
description: Agent requests approval via useFrontendTool with an async handler; the approval UI pops up as an app-level modal OUTSIDE the chat, and a completion callback resolves the pending tool Promise with the user's decision
tags:
- interactivity
route: /demos/hitl-in-app
animated_preview_url:
highlight:
- agent/HitlInAppAgent.cs
- src/app/demos/hitl-in-app/page.tsx
- src/app/demos/hitl-in-app/approval-dialog.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat
name: In-Chat HITL (useHumanInTheLoop — ergonomic API)
description: Interactive approval/decision surface rendered inline in the chat via the high-level `useHumanInTheLoop` hook
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- agent/HitlInChatAgent.cs
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/gen-ui-tool-based/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering
name: Tool Rendering
description: Backend agent tools rendered as UI components
tags:
- agent-capabilities
route: /demos/tool-rendering
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/tool-rendering/page.tsx
- id: gen-ui-agent
name: Agentic Generative UI
description: Long-running agent tasks with generated UI
tags:
- generative-ui
route: /demos/gen-ui-agent
animated_preview_url:
- id: shared-state-streaming
name: State Streaming
description: Per-token state delta streaming from agent to UI
tags:
- agent-state
route: /demos/shared-state-streaming
animated_preview_url:
- id: shared-state-read-write
name: Shared State (Read + Write)
description: Bidirectional agent state — UI writes preferences, agent writes notes back via a set_notes tool
tags:
- agent-state
route: /demos/shared-state-read-write
animated_preview_url:
highlight:
- agent/SharedStateReadWriteAgent.cs
- agent/Program.cs
- src/app/demos/shared-state-read-write/page.tsx
- src/app/demos/shared-state-read-write/preferences-card.tsx
- src/app/demos/shared-state-read-write/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: subagents
name: Sub-Agents
description: Supervisor delegates to research / writing / critique sub-agents with a live delegation log
tags:
- multi-agent
route: /demos/subagents
animated_preview_url:
highlight:
- agent/SubagentsAgent.cs
- agent/Program.cs
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-sidebar
name: "Pre-Built: Sidebar"
description: Docked sidebar chat via <CopilotSidebar />
tags:
- chat-ui
route: /demos/prebuilt-sidebar
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/prebuilt-sidebar/page.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-popup
name: "Pre-Built: Popup"
description: Floating popup chat via <CopilotPopup />
tags:
- chat-ui
route: /demos/prebuilt-popup
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/prebuilt-popup/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-slots
name: Chat Customization (Slots)
description: Customize CopilotChat via its slot system
tags:
- chat-ui
route: /demos/chat-slots
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/chat-slots/page.tsx
- src/app/demos/chat-slots/custom-welcome-screen.tsx
- src/app/api/copilotkit/route.ts
- id: headless-simple
name: Headless Chat (Simple)
description: Minimal custom chat surface built on useAgent
tags:
- chat-ui
route: /demos/headless-simple
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: headless-complete
name: Headless Chat (Complete)
description: Full chat implementation built from scratch on useAgent
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/headless-complete/page.tsx
- src/app/demos/headless-complete/message-list.tsx
- src/app/demos/headless-complete/use-rendered-messages.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-interrupt
name: In-Chat HITL (useInterrupt — low-level primitive)
description: Interactive component rendered inline in the chat via the lower-level `useInterrupt` primitive — direct control over the interrupt lifecycle
tags:
- generative-ui
route: /demos/gen-ui-interrupt
animated_preview_url:
highlight:
- agent/InterruptAgent.cs
- src/app/demos/gen-ui-interrupt/page.tsx
- src/app/demos/gen-ui-interrupt/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-gen-ui
name: Declarative Generative UI (A2UI)
description: Canonical A2UI BYOC — custom catalog (Card/StatusBadge/Metric/InfoRow/PrimaryButton) wired via a2ui.catalog on the provider; runtime injects the render_a2ui tool automatically
tags:
- generative-ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- agent/DeclarativeGenUiAgent.cs
- src/app/demos/declarative-gen-ui/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
- src/app/api/copilotkit/route.ts
- id: a2ui-fixed-schema
name: Declarative Generative UI (A2UI — Fixed Schema)
description: A2UI rendering against a known client-side schema
tags:
- generative-ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- agent/A2uiFixedSchemaAgent.cs
- src/app/demos/a2ui-fixed-schema/page.tsx
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
- src/app/api/copilotkit/route.ts
- id: mcp-apps
name: MCP Apps
description: MCP server-driven UI via activity renderers
tags:
- generative-ui
route: /demos/mcp-apps
animated_preview_url:
highlight:
- agent/McpAppsAgent.cs
- src/app/demos/mcp-apps/page.tsx
- src/app/api/copilotkit-mcp-apps/[[...slug]]/route.ts
- id: interrupt-headless
name: Headless Interrupt (testing)
description: Resolve interrupts from a plain button grid — no chat, no useInterrupt render prop
tags:
- interactivity
route: /demos/interrupt-headless
animated_preview_url:
highlight:
- agent/InterruptAgent.cs
- src/app/demos/interrupt-headless/page.tsx
- src/app/api/copilotkit/route.ts
- id: readonly-state-agent-context
name: Readonly State (Agent Context)
description: Frontend provides read-only context to the agent via useAgentContext
tags:
- agent-state
route: /demos/readonly-state-agent-context
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: reasoning-default
name: Reasoning (Default Render)
description: Built-in CopilotChatReasoningMessage renders without a custom slot
tags:
- generative-ui
route: /demos/reasoning-default
animated_preview_url:
highlight:
- agent/ReasoningAgent.cs
- src/app/demos/reasoning-default/page.tsx
- src/app/api/copilotkit/route.ts
- id: reasoning-custom
name: Reasoning (Custom Render)
description: Custom reasoning slot renders the model's visible thinking chain
tags:
- generative-ui
route: /demos/reasoning-custom
animated_preview_url:
highlight:
- agent/ReasoningAgent.cs
- src/app/demos/reasoning-custom/page.tsx
- src/app/demos/reasoning-custom/reasoning-block.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-reasoning-chain
name: Tool Rendering + Reasoning Chain (testing)
description: Sequential tool calls with reasoning tokens rendered side-by-side
tags:
- generative-ui
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- agent/ReasoningAgent.cs
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-json-render
name: BYOC json-render
description: Streaming hierarchical JSON UI spec rendered via @json-render/react, with a Zod-validated catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-json-render
animated_preview_url:
highlight:
- agent/ByocJsonRenderAgent.cs
- src/app/demos/declarative-json-render/page.tsx
- src/app/demos/declarative-json-render/json-render-renderer.tsx
- src/app/demos/declarative-json-render/registry.tsx
- src/app/demos/declarative-json-render/catalog.ts
- src/app/demos/declarative-json-render/metric-card.tsx
- src/app/demos/declarative-json-render/charts/bar-chart.tsx
- src/app/demos/declarative-json-render/charts/pie-chart.tsx
- src/app/demos/declarative-json-render/types.ts
- src/app/demos/declarative-json-render/suggestions.ts
- src/app/api/copilotkit-declarative-json-render/route.ts
- id: beautiful-chat
name: Beautiful Chat
description: Canonical polished starter chat — brand fonts, theme tokens, suggestion pills
tags:
- chat-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- agent/BeautifulChatAgent.cs
- src/app/demos/beautiful-chat/page.tsx
- src/app/demos/beautiful-chat/components/example-layout/index.tsx
- src/app/demos/beautiful-chat/components/example-canvas/index.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/bar-chart.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/pie-chart.tsx
- src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
- src/app/api/copilotkit-beautiful-chat/route.ts
- id: multimodal
name: Multimodal Attachments
description: Image and PDF uploads via CopilotChat attachments, processed by a vision-capable agent
tags:
- chat-ui
route: /demos/multimodal
animated_preview_url:
highlight:
- agent/MultimodalAgent.cs
- src/app/demos/multimodal/page.tsx
- src/app/demos/multimodal/sample-attachment-buttons.tsx
- src/app/api/copilotkit-multimodal/route.ts
- id: auth
name: Authentication
description: Bearer-token gate via runtime onRequest hook with unauthenticated / authenticated states
tags:
- chat-ui
route: /demos/auth
animated_preview_url:
highlight:
- src/app/demos/auth/page.tsx
- src/app/demos/auth/auth-banner.tsx
- src/app/demos/auth/use-demo-auth.ts
- src/app/demos/auth/demo-token.ts
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
- id: declarative-hashbrown
name: BYOC Hashbrown
description: Streaming structured output via @hashbrownai/react, rendering a sales dashboard catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-hashbrown
animated_preview_url:
highlight:
- agent/ByocHashbrownAgent.cs
- src/app/demos/declarative-hashbrown/page.tsx
- src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx
- src/app/demos/declarative-hashbrown/metric-card.tsx
- src/app/demos/declarative-hashbrown/charts/bar-chart.tsx
- src/app/demos/declarative-hashbrown/charts/pie-chart.tsx
- src/app/demos/declarative-hashbrown/types.ts
- src/app/demos/declarative-hashbrown/suggestions.ts
- src/app/api/copilotkit-declarative-hashbrown/route.ts
- id: open-gen-ui
name: Fully Open-Ended Generative UI
description: Agent generates UI from an arbitrary component library
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- agent/OpenGenUiAgent.cs
- src/app/demos/open-gen-ui/page.tsx
- src/app/api/copilotkit-ogui/route.ts
- id: open-gen-ui-advanced
name: "Open-Ended Gen UI (Advanced: with frontend function calling)"
description: Agent-authored UI that can invoke frontend sandbox functions from inside the iframe
tags:
- generative-ui
route: /demos/open-gen-ui-advanced
animated_preview_url:
highlight:
- agent/OpenGenUiAdvancedAgent.cs
- src/app/demos/open-gen-ui-advanced/page.tsx
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
- src/app/demos/open-gen-ui-advanced/suggestions.ts
- src/app/api/copilotkit-ogui/route.ts
- id: voice
name: Voice Input
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- agent/Program.cs
- src/app/demos/voice/page.tsx
- src/app/demos/voice/sample-audio-button.tsx
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
- id: agent-config
name: Agent Config Object
description: Forward a typed config object (tone / expertise / response length) from the provider to the agent's dynamic system prompt
tags:
- platform
route: /demos/agent-config
animated_preview_url:
highlight:
- agent/AgentConfigAgent.cs
- src/app/demos/agent-config/page.tsx
- src/app/demos/agent-config/config-card.tsx
- src/app/demos/agent-config/use-agent-config.ts
- src/app/demos/agent-config/config-types.ts
- src/app/api/copilotkit-agent-config/route.ts
@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
typescript: { ignoreBuildErrors: true },
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
{
"name": "@copilotkit/showcase-ms-agent-dotnet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev --turbopack\" \"dotnet run --project agent --urls http://localhost:8000\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.2",
"@copilotkit/react-core": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@copilotkit/shared": "1.61.2",
"@copilotkit/voice": "1.61.2",
"@hashbrownai/core": "0.5.0-beta.4",
"@hashbrownai/react": "0.5.0-beta.4",
"@json-render/core": "0.18.0",
"@json-render/react": "0.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"openai": "^5.9.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"yaml": "^2.8.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"concurrently": "^9.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"@copilotkit/web-inspector": {
"@copilotkit/core": "1.61.2"
}
},
"pnpm": {
"overrides": {
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
}
}
}
@@ -0,0 +1,45 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
// Local default of 1 retry covers the `agent_framework.Agent` concurrency
// flake: the shared Agent instance + single OpenAI HTTP client serialise
// SSE streams under high worker counts, so a handful of cells time out at
// 30s on the first attempt and pass cleanly on retry. CI keeps 2 retries
// as the safer ratchet. The underlying upstream fix is per-request agent
// instantiation; see the D5 sweep doc in Notion.
retries: process.env.CI ? 2 : 1,
// Local default of 4 workers caps the SSE concurrency the python agent has
// to absorb. The reused `agent_framework.Agent` + shared OpenAI HTTP client
// serialise concurrent streams; >4 workers makes 30s test timeouts inevitable
// for a few cells. 4 keeps things parallel without overloading the agent.
workers: process.env.CI ? 1 : 4,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
extraHTTPHeaders: {
"X-AIMock-Context": "ms-agent-dotnet",
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
env: {
...process.env,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
},
},
});
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,30 @@
<svg viewBox="0 0 224.43 237.166" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M76.4556 75.7547C97.0552 48.81 114.152 22.1656 120.731 0.648813C120.908 0.0633572 121.594 -0.185355 122.103 0.152636C144.979 15.3013 186.646 25.2726 223.5 25.5066C224.134 25.5107 224.571 26.1359 224.342 26.7272C212.088 57.8146 197.122 113.518 196.54 177.128C196.54 178.073 195.21 178.412 194.742 177.59C173.768 140.886 106.586 89.3107 76.7986 77.138C76.2477 76.9114 76.0919 76.2307 76.4556 75.7547Z" fill="url(#paint0_linear)"/>
<path d="M145.956 59.273C113.757 69.4674 84.3336 75.1349 77.3077 76.4226C76.8608 76.5047 76.7673 77.1231 77.1934 77.2977C107.209 89.777 174.059 141.202 194.835 177.757C194.877 177.837 194.981 177.867 195.064 177.83C195.147 177.791 195.189 177.687 195.158 177.597L145.956 59.273Z" fill="url(#paint1_linear)"/>
<path d="M122.197 0.0860308C149.76 15.1211 181.615 21.8738 223.875 25.4319C224.135 25.4546 224.228 25.8103 223.989 25.9339C218.585 28.7116 187.623 44.4667 164.633 52.905C158.47 55.1657 152.275 57.263 146.174 59.1979C146.039 59.2402 145.894 59.1736 145.842 59.0446L121.563 0.655481C121.397 0.262302 121.823 -0.117886 122.197 0.0860308Z" fill="url(#paint2_linear)"/>
<path d="M121.361 0.145972C121.761 -0.0218955 122.214 0.121754 122.45 0.467128L122.536 0.6264L196.496 177.058L196.548 177.233C196.628 177.642 196.415 178.065 196.015 178.233C195.615 178.401 195.162 178.257 194.926 177.912L194.838 177.753L120.881 1.32093L120.826 1.14599C120.745 0.736568 120.961 0.313703 121.361 0.145972Z" fill="#513C9F"/>
<path d="M223.089 25.5869C223.52 25.3424 224.069 25.4925 224.313 25.9238C224.558 26.3552 224.406 26.9035 223.974 27.1483V27.1509H223.969C223.965 27.153 223.96 27.1575 223.953 27.1614C223.939 27.1695 223.916 27.1821 223.888 27.1979C223.832 27.2294 223.749 27.2755 223.64 27.3363C223.419 27.4593 223.091 27.6413 222.661 27.8768C221.8 28.3482 220.529 29.0371 218.885 29.9029C215.597 31.6348 210.813 34.0821 204.83 36.9423C192.865 42.6619 176.091 50.0388 156.86 56.6737C137.624 63.3089 117.782 68.4614 102.755 71.9534C95.2398 73.6998 88.9245 75.0317 84.4882 75.9274C82.2701 76.3752 80.5211 76.7135 79.3262 76.9405C78.7293 77.0538 78.2706 77.1391 77.9606 77.1963C77.8058 77.2249 77.6873 77.2472 77.6081 77.2616C77.5693 77.2687 77.5395 77.2737 77.5194 77.2773C77.5095 77.2791 77.501 77.2816 77.4959 77.2825H77.488L77.3052 77.2982C76.8881 77.2877 76.5205 76.9859 76.4436 76.5593C76.356 76.0711 76.6815 75.6027 77.1695 75.5149H77.1773C77.182 75.514 77.1888 75.5113 77.1982 75.5096C77.2176 75.5061 77.2481 75.501 77.287 75.494C77.3647 75.4798 77.4812 75.4595 77.6342 75.4313C77.9413 75.3746 78.3978 75.2882 78.992 75.1754C80.1806 74.9497 81.9227 74.6138 84.1331 74.1676C88.5551 73.2748 94.8521 71.9459 102.348 70.204C117.342 66.7197 137.119 61.5841 156.276 54.9766C175.426 48.3694 192.134 41.0193 204.055 35.3208C210.015 32.4718 214.777 30.0352 218.047 28.3128C219.682 27.4518 220.944 26.7694 221.796 26.3024C222.222 26.0693 222.546 25.8906 222.763 25.7697C222.871 25.7092 222.954 25.6619 223.008 25.6313C223.035 25.6163 223.055 25.6049 223.068 25.5974C223.075 25.5937 223.08 25.5914 223.084 25.5895L223.086 25.5869H223.089Z" fill="#513C9F"/>
<path d="M2.77027 236.611C2.20838 237.273 1.21525 237.353 0.553517 236.792C-0.107154 236.23 -0.18789 235.239 0.373357 234.577L2.77027 236.611ZM132.306 21.7584C133.136 22.0085 133.607 22.8858 133.358 23.7167L106.569 112.86H169.57L169.886 112.891C170.602 113.037 171.142 113.672 171.142 114.431C171.142 115.191 170.602 115.825 169.886 115.972L169.57 116.003H105.182L2.77027 236.611L1.57181 235.593L0.373357 234.577L103.044 113.664L130.347 22.8133C130.597 21.9819 131.474 21.5086 132.306 21.7584Z" fill="#ABABAB"/>
<path d="M72.9636 210.65L60.8346 212.356C67.1226 228.985 80.0206 236.249 95.4131 236.249C133.141 236.249 121.625 193.585 143.482 193.585C159.342 193.585 152.899 228.165 187.02 228.165C207.848 228.165 209.927 207.183 206.372 198.156C206.351 198.101 206.331 198.051 206.299 198.002L200.718 189.455C200.355 188.887 199.471 189.101 199.409 189.776L198.369 200.135C198.297 200.856 198.317 201.574 198.401 202.293C199.253 209.45 199.804 226.818 187.02 226.818C173.54 226.818 170.297 192.686 143.482 192.686C112.032 192.686 116.075 234.902 96.7643 234.902C84.0221 234.902 74.3043 220.531 72.9636 210.65Z" fill="url(#paint3_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="171.825" y1="13.8344" x2="135.895" y2="112.635" gradientUnits="userSpaceOnUse">
<stop stop-color="#6430AB"/>
<stop offset="1" stop-color="#AA89D8"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="143.981" y1="69.5214" x2="97.7306" y2="158.891" gradientUnits="userSpaceOnUse">
<stop stop-color="#005DBB"/>
<stop offset="1" stop-color="#3D92E8"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="164.633" y1="13.8337" x2="150.706" y2="57.3959" gradientUnits="userSpaceOnUse">
<stop stop-color="#1B70C4"/>
<stop offset="1" stop-color="#54A4F2"/>
</linearGradient>
<linearGradient id="paint3_linear" x1="60.8346" y1="213.57" x2="207.775" y2="213.57" gradientUnits="userSpaceOnUse">
<stop stop-color="#4497EA"/>
<stop offset="0.254755" stop-color="#1463B2"/>
<stop offset="0.498725" stop-color="#0A437D"/>
<stop offset="0.666667" stop-color="#2476C8"/>
<stop offset="0.972542" stop-color="#0C549A"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1,46 @@
<svg viewBox="0 0 1044.21 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(7.74 31.74)">
<g id="CopilotKit">
<path d="M111.689 53.5173H78.8802C78.647 50.8028 78.0267 48.3403 77.0185 46.1299C76.0487 43.9195 74.6918 42.0192 72.9466 40.4291C71.2403 38.8006 69.1654 37.5591 66.7223 36.7063C64.2791 35.8143 61.5062 35.3683 58.4038 35.3683C52.9745 35.3683 48.379 36.6867 44.6173 39.3235C40.8943 41.9611 38.0633 45.742 36.1243 50.6669C34.224 55.5919 33.2739 61.5063 33.2739 68.4092C33.2739 75.7001 34.2434 81.8084 36.1825 86.7334C38.1602 91.6199 41.0106 95.3042 44.7336 97.7855C48.4566 100.229 52.9358 101.45 58.1711 101.45C61.1573 101.45 63.8331 101.082 66.1988 100.345C68.5644 99.5695 70.6198 98.4647 72.3649 97.0293C74.11 95.5947 75.5258 93.8687 76.6117 91.8523C77.7362 89.7965 78.4923 87.4895 78.8802 84.9297L111.689 85.1629C111.301 90.2041 109.885 95.3426 107.442 100.578C104.999 105.774 101.528 110.583 97.0293 115.004C92.57 119.386 87.0435 122.916 80.4507 125.591C73.8579 128.267 66.1988 129.605 57.4731 129.605C46.5369 129.605 36.7254 127.259 28.0384 122.567C19.3904 117.874 12.5456 110.971 7.50404 101.858C2.50134 92.7443 0 81.5948 0 68.4092C0 55.1467 2.55952 43.9776 7.6786 34.9026C12.7977 25.7893 19.7006 18.9057 28.3875 14.252C37.0744 9.55957 46.7696 7.2133 57.4731 7.2133C64.9965 7.2133 71.9189 8.24101 78.2403 10.2964C84.5614 12.3518 90.1067 15.3572 94.877 19.3129C99.6472 23.2297 103.486 28.0578 106.395 33.7978C109.303 39.5371 111.068 46.1102 111.689 53.5173Z" fill="#010507"/>
<path d="M169.874 129.606C160.179 129.606 151.88 127.686 144.977 123.847C138.074 119.968 132.781 114.578 129.096 107.675C125.412 100.733 123.57 92.6864 123.57 83.5337C123.57 74.3818 125.412 66.3544 129.096 59.4515C132.781 52.5092 138.074 47.1186 144.977 43.2796C151.88 39.4014 160.179 37.4626 169.874 37.4626C179.569 37.4626 187.868 39.4014 194.771 43.2796C201.674 47.1186 206.968 52.5092 210.652 59.4515C214.336 66.3544 216.178 74.3818 216.178 83.5337C216.178 92.6864 214.336 100.733 210.652 107.675C206.968 114.578 201.674 119.968 194.771 123.847C187.868 127.686 179.569 129.606 169.874 129.606ZM170.107 105.872C172.822 105.872 175.168 104.96 177.145 103.138C179.123 101.315 180.655 98.7169 181.741 95.3428C182.827 91.9686 183.37 87.9553 183.37 83.3013C183.37 78.6087 182.827 74.5946 181.741 71.2597C180.655 67.8856 179.123 65.2873 177.145 63.4648C175.168 61.6422 172.822 60.7306 170.107 60.7306C167.237 60.7306 164.775 61.6422 162.719 63.4648C160.664 65.2873 159.093 67.8856 158.007 71.2597C156.922 74.5946 156.378 78.6087 156.378 83.3013C156.378 87.9553 156.922 91.9686 158.007 95.3428C159.093 98.7169 160.664 101.315 162.719 103.138C164.775 104.96 167.237 105.872 170.107 105.872Z" fill="#010507"/>
<path d="M229.208 161.484V38.6256H261.086V54.2155H261.784C262.948 51.1908 264.655 48.4182 266.903 45.8976C269.153 43.3377 271.945 41.3016 275.28 39.7893C278.615 38.2376 282.493 37.4626 286.914 37.4626C292.809 37.4626 298.413 39.0331 303.726 42.174C309.077 45.3157 313.421 50.2603 316.756 57.0078C320.13 63.7561 321.817 72.52 321.817 83.3013C321.817 93.6168 320.208 102.168 316.989 108.955C313.809 115.741 309.543 120.802 304.191 124.137C298.878 127.473 293.041 129.14 286.682 129.14C282.493 129.14 278.751 128.461 275.455 127.104C272.197 125.708 269.405 123.827 267.078 121.462C264.79 119.057 263.025 116.342 261.784 113.317H261.319V161.484H229.208ZM260.621 83.3013C260.621 87.6444 261.183 91.4064 262.308 94.5866C263.471 97.7275 265.101 100.171 267.195 101.916C269.327 103.622 271.868 104.476 274.815 104.476C277.762 104.476 280.263 103.642 282.319 101.974C284.413 100.268 286.003 97.8437 287.089 94.7028C288.214 91.5226 288.776 87.7221 288.776 83.3013C288.776 78.8804 288.214 75.0995 287.089 71.9578C286.003 68.7776 284.413 66.3544 282.319 64.6866C280.263 62.9803 277.762 62.1267 274.815 62.1267C271.868 62.1267 269.327 62.9803 267.195 64.6866C265.101 66.3544 263.471 68.7776 262.308 71.9578C261.183 75.0995 260.621 78.8804 260.621 83.3013Z" fill="#010507"/>
<path d="M335.269 127.977V38.6253H367.38V127.977H335.269ZM351.324 29.318C346.981 29.318 343.258 27.8834 340.156 25.0136C337.054 22.1438 335.502 18.6923 335.502 14.6592C335.502 10.6259 337.054 7.17442 340.156 4.30463C343.258 1.43485 346.981 0 351.324 0C355.707 0 359.429 1.43485 362.493 4.30463C365.596 7.17442 367.147 10.6259 367.147 14.6592C367.147 18.6923 365.596 22.1438 362.493 25.0136C359.429 27.8834 355.707 29.318 351.324 29.318Z" fill="#010507"/>
<path d="M415.851 8.84214V127.977H383.74V8.84214H415.851Z" fill="#010507"/>
<path d="M475.258 129.606C465.563 129.606 457.264 127.686 450.361 123.847C443.458 119.968 438.164 114.578 434.48 107.675C430.796 100.733 428.954 92.6864 428.954 83.5337C428.954 74.3818 430.796 66.3544 434.48 59.4515C438.164 52.5092 443.458 47.1186 450.361 43.2796C457.264 39.4014 465.563 37.4626 475.258 37.4626C484.954 37.4626 493.253 39.4014 500.156 43.2796C507.059 47.1186 512.352 52.5092 516.036 59.4515C519.72 66.3544 521.563 74.3818 521.563 83.5337C521.563 92.6864 519.72 100.733 516.036 107.675C512.352 114.578 507.059 119.968 500.156 123.847C493.253 127.686 484.954 129.606 475.258 129.606ZM475.491 105.872C478.205 105.872 480.552 104.96 482.53 103.138C484.508 101.315 486.039 98.7169 487.125 95.3428C488.211 91.9686 488.754 87.9553 488.754 83.3013C488.754 78.6087 488.211 74.5946 487.125 71.2597C486.039 67.8856 484.508 65.2873 482.53 63.4648C480.552 61.6422 478.205 60.7306 475.491 60.7306C472.621 60.7306 470.158 61.6422 468.103 63.4648C466.048 65.2873 464.477 67.8856 463.391 71.2597C462.305 74.5946 461.763 78.6087 461.763 83.3013C461.763 87.9553 462.305 91.9686 463.391 95.3428C464.477 98.7169 466.048 101.315 468.103 103.138C470.158 104.96 472.621 105.872 475.491 105.872Z" fill="#010507"/>
<path d="M587.877 38.6253V61.8941H529.008V38.6253H587.877ZM540.41 17.2186H572.52V99.2396C572.52 100.481 572.734 101.528 573.16 102.381C573.587 103.196 574.246 103.816 575.138 104.242C576.03 104.63 577.174 104.824 578.57 104.824C579.539 104.824 580.664 104.708 581.944 104.476C583.262 104.242 584.232 104.048 584.852 103.894L589.506 126.464C588.072 126.891 586.016 127.415 583.34 128.035C580.703 128.655 577.561 129.063 573.916 129.256C566.548 129.644 560.363 128.888 555.36 126.987C550.357 125.049 546.595 122.004 544.075 117.854C541.554 113.705 540.332 108.508 540.41 102.265V17.2186Z" fill="#010507"/>
<path d="M602.435 127.977V8.84214H634.778V57.0077H636.406L672.24 8.84214H709.935L669.681 61.8942L710.866 127.977H672.24L645.481 83.3012L634.778 97.2626V127.977H602.435Z" fill="#010507"/>
<path d="M725.597 127.977V38.6253H757.708V127.977H725.597ZM741.653 29.318C737.309 29.318 733.586 27.8834 730.484 25.0136C727.381 22.1438 725.831 18.6923 725.831 14.6592C725.831 10.6259 727.381 7.17442 730.484 4.30463C733.586 1.43485 737.309 0 741.653 0C746.035 0 749.758 1.43485 752.821 4.30463C755.924 7.17442 757.475 10.6259 757.475 14.6592C757.475 18.6923 755.924 22.1438 752.821 25.0136C749.758 27.8834 746.035 29.318 741.653 29.318Z" fill="#010507"/>
<path d="M827.354 38.6253V61.8941H768.484V38.6253H827.354ZM779.886 17.2186H811.994V99.2396C811.994 100.481 812.206 101.528 812.632 102.381C813.066 103.196 813.72 103.816 814.612 104.242C815.504 104.63 816.65 104.824 818.05 104.824C819.015 104.824 820.136 104.708 821.421 104.476C822.739 104.242 823.704 104.048 824.326 103.894L828.983 126.464C827.551 126.891 825.489 127.415 822.812 128.035C820.177 128.655 817.035 129.063 813.393 129.256C806.024 129.644 799.838 128.888 794.836 126.987C789.833 125.049 786.071 122.004 783.55 117.854C781.03 113.705 779.808 108.508 779.886 102.265V17.2186Z" fill="#010507"/>
</g>
</g>
<svg x="859.58" y="7.1" width="176.717" height="186.745" viewBox="0 0 176.717 186.745" fill="none">
<path d="M60.2013 59.6493C76.4214 38.4331 89.8836 17.4532 95.0639 0.510877C95.203 0.0498883 95.7431 -0.145948 96.1441 0.120187C114.156 12.0483 146.965 19.8996 175.984 20.0839C176.484 20.0871 176.827 20.5795 176.647 21.045C166.999 45.5233 155.214 89.384 154.756 139.471C154.756 140.215 153.708 140.482 153.34 139.835C136.825 110.934 83.9258 70.3234 60.4713 60.7386C60.0376 60.5602 59.9148 60.0242 60.2013 59.6493Z" fill="url(#paint0_linear)"/>
<path d="M114.926 46.6717C89.5724 54.6988 66.4044 59.1614 60.8722 60.1754C60.5203 60.24 60.4466 60.7269 60.7822 60.8644C84.4167 70.6906 137.054 111.183 153.414 139.967C153.446 140.03 153.528 140.052 153.594 140.024C153.659 139.993 153.692 139.911 153.667 139.841L114.926 46.6717Z" fill="url(#paint1_linear)"/>
<path d="M96.218 0.0677872C117.921 11.9064 143.004 17.2235 176.279 20.0252C176.484 20.043 176.558 20.3231 176.369 20.4204C172.114 22.6076 147.734 35.0132 129.632 41.6575C124.779 43.4376 119.902 45.089 115.098 46.6126C114.991 46.6459 114.877 46.5934 114.836 46.4919L95.7188 0.516173C95.5878 0.206583 95.9234 -0.0927773 96.218 0.0677872Z" fill="url(#paint2_linear)"/>
<path d="M95.5597 0.114978C95.875 -0.0172013 96.2317 0.0959082 96.417 0.367856L96.4849 0.493267L154.721 139.416L154.762 139.554C154.825 139.876 154.657 140.209 154.343 140.341C154.027 140.473 153.671 140.36 153.485 140.088L153.415 139.963L95.1814 1.04014L95.1383 0.902396C95.075 0.580014 95.2449 0.247049 95.5597 0.114978Z" fill="#513C9F"/>
<path d="M175.661 20.1472C176 19.9546 176.432 20.0729 176.625 20.4124C176.818 20.7522 176.697 21.1839 176.357 21.3767V21.3787H176.353C176.351 21.3803 176.346 21.3839 176.341 21.3869C176.33 21.3933 176.312 21.4033 176.29 21.4157C176.246 21.4405 176.18 21.4768 176.094 21.5247C175.921 21.6215 175.662 21.7648 175.323 21.9503C174.646 22.3215 173.645 22.8639 172.351 23.5456C169.761 24.9093 165.995 26.8363 161.283 29.0884C151.862 33.592 138.655 39.4006 123.512 44.625C108.365 49.8495 92.7416 53.9066 80.9093 56.6563C74.9919 58.0314 70.0192 59.0801 66.5261 59.7854C64.7796 60.138 63.4024 60.4044 62.4615 60.5831C61.9916 60.6723 61.6304 60.7395 61.3863 60.7845C61.2644 60.807 61.1711 60.8246 61.1087 60.8359C61.0782 60.8415 61.0547 60.8454 61.0388 60.8483C61.0311 60.8497 61.0244 60.8517 61.0203 60.8524H61.0142L60.8702 60.8647C60.5418 60.8565 60.2523 60.6188 60.1918 60.2829C60.1228 59.8986 60.3791 59.5297 60.7633 59.4605H60.7695C60.7732 59.4599 60.7786 59.4578 60.786 59.4564C60.8013 59.4537 60.8252 59.4497 60.8559 59.4441C60.9171 59.4329 61.0088 59.417 61.1293 59.3947C61.3711 59.3501 61.7305 59.2821 62.1984 59.1933C63.1343 59.0155 64.506 58.7511 66.2465 58.3997C69.7284 57.6967 74.6866 56.6503 80.5886 55.2788C92.3949 52.5352 107.968 48.4914 123.052 43.2887C138.13 38.0862 151.286 32.2987 160.673 27.8117C165.366 25.5684 169.116 23.6497 171.691 22.2936C172.978 21.6156 173.972 21.0783 174.643 20.7105C174.978 20.527 175.233 20.3863 175.404 20.2911C175.489 20.2435 175.554 20.2062 175.597 20.1822C175.618 20.1704 175.634 20.1614 175.644 20.1554C175.649 20.1525 175.654 20.1507 175.656 20.1493L175.658 20.1472H175.661Z" fill="#513C9F"/>
<path d="M2.18131 186.308C1.73888 186.829 0.956885 186.893 0.435837 186.45C-0.0843773 186.008 -0.147949 185.227 0.293978 184.707L2.18131 186.308ZM104.178 17.1327C104.832 17.3295 105.202 18.0203 105.006 18.6746L83.9124 88.8658H133.52L133.768 88.8905C134.333 89.0058 134.757 89.5053 134.757 90.1035C134.757 90.7016 134.333 91.2011 133.768 91.3164L133.52 91.3411H82.8207L2.18131 186.308L1.23765 185.506L0.293978 184.707L81.1369 89.499L102.636 17.9632C102.832 17.3086 103.523 16.9359 104.178 17.1327Z" fill="#ABABAB"/>
<path d="M57.4516 165.866L47.9012 167.209C52.8524 180.303 63.0083 186.023 75.1284 186.023C104.835 186.023 95.7677 152.43 112.978 152.43C125.466 152.43 120.393 179.658 147.26 179.658C163.66 179.658 165.297 163.136 162.498 156.029C162.481 155.985 162.465 155.946 162.44 155.907L158.046 149.177C157.759 148.73 157.064 148.898 157.015 149.43L156.196 157.587C156.139 158.154 156.155 158.72 156.221 159.286C156.892 164.921 157.326 178.597 147.26 178.597C136.645 178.597 134.092 151.722 112.978 151.722C88.2142 151.722 91.3977 184.962 76.1923 184.962C66.1591 184.962 58.5073 173.647 57.4516 165.866Z" fill="url(#paint3_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="135.295" y1="10.8932" x2="107.004" y2="88.6892" gradientUnits="userSpaceOnUse">
<stop stop-color="#6430AB"/>
<stop offset="1" stop-color="#AA89D8"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="113.371" y1="54.7414" x2="76.9532" y2="125.111" gradientUnits="userSpaceOnUse">
<stop stop-color="#005DBB"/>
<stop offset="1" stop-color="#3D92E8"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="129.632" y1="10.8927" x2="118.666" y2="45.1937" gradientUnits="userSpaceOnUse">
<stop stop-color="#1B70C4"/>
<stop offset="1" stop-color="#54A4F2"/>
</linearGradient>
<linearGradient id="paint3_linear" x1="47.9012" y1="168.165" x2="163.603" y2="168.165" gradientUnits="userSpaceOnUse">
<stop stop-color="#4497EA"/>
<stop offset="0.254755" stop-color="#1463B2"/>
<stop offset="0.498725" stop-color="#0A437D"/>
<stop offset="0.666667" stop-color="#2476C8"/>
<stop offset="0.972542" stop-color="#0C549A"/>
</linearGradient>
</defs>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
size 87078
@@ -0,0 +1,9 @@
# These two demo files are committed as real binaries (not LFS pointers)
# because the CI Docker build doesn't run `git lfs pull` — production
# would otherwise ship the pointer-text and the Try-with-sample-{image,pdf}
# buttons reject it with "Git LFS pointer, not the real asset".
#
# langgraph-python's demo-files have the same setup (their root
# `.gitattributes` `*.png filter=lfs` is overridden here per-path).
sample.png -filter -diff -merge
sample.pdf -filter -diff -merge
@@ -0,0 +1,22 @@
# Demo Files — Multimodal Demo
This directory bundles sample files referenced by the `/demos/multimodal` page.
Required files (must be committed as binaries; see `.gitattributes` at repo root):
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
injects into the chat. A CopilotKit logo or other recognizable brand mark
works well so the vision-capable agent has something to describe.
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
button injects. The content must mention "CopilotKit" so E2E soft
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
callback the paperclip button uses — so the sample path exercises the exact
same queueing code as a real user upload.
If these files are missing, the demo page still renders but the sample
buttons will surface a fetch error. The paperclip / drag-and-drop paths
continue to work without them.
@@ -0,0 +1,80 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (CopilotKit) /CreationDate (D:20260424104452+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424104452+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (CopilotKit Quickstart) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 1 /Kids [ 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 942
>>
stream
Gat=)gMRoa&:N^l7S$NN`GHCmThV;Z2^rJ`P):Y@]XYQU3:,AeX>cqAr-QI2/=2N"LfPX,loA_(*1m\5btm94UD2YX&6drn#XTAg+m:hr0`277L6Xugi+Zi-%K4(Zk-_X)UIg0#"[s_$PB)p"JhpIS$sekH(N!]Wm13pi-C<+2Z\%NM%.@X"c6;!4Z\-]lQ_FW]'b$98<b+A;AK8VL'gsRZ%'52u3[a]V,-i>M8Rs*Je-b?d:1;&?$,`ad5V$Xf0gWUq#g$2OoROl&VT9iG>Z7e:(6,L^5qWRe9f&aq&5@k4gFJA#"jE(QdPamJY0Hpqn>r_e:*FVtpYPMT?RZaJ8P7Ca0<':`;qM'##`XXPXO9RF6i%iKqn<L:oPEI$hcphT25S/nN3<DAK4NVdiJL>7#+eNHaXE@Y[_>W6oJG]uZn6;#DFBr_^iD&OGjVg%"ij8a:&7/qaE+7a=d``5hdQO":;.r]$4@TOAQt"^6*,kR%u>XHZrCjf@5l+)<U?'C_6MX"fJq<J@]>%B@BumKlTA+:A9AG5jqSXcFh[j7G)iN0D\N/+I[;hq]7D]ZAE\d8UW,N3+.Br<N@#$kD%W'f-CmKrYTKkeeuX8:pui+(3#6dK@@XC>.sl!SW4(X]/!H@1dH0Qp(55E^KJk1]UY=9;_"eb*-cL!Qq,hJ?,?l*JeVfts!/i_[Ejo:h;fDQSkbMmd[X?rTSO%M5Qeh<MleEs/@&O$fl?bGg9%rX0LW>9/:K"H3?p7"G:0Ha3Qj[6jL<;9nL312;])jCZe6\\@E*Ttr?Xg2lNp?68b2o5,cF;--/kSU`mMnHk40JE<",2f4YKj@2GX^Q[V>`.2@i]-rEiq2ARgR#W*>Hsb'riEWK24?;X\G?nL'id4l2fR3ocbEr3QJOgLr..O8N<DDWc>P*6r.',3Q1&LkGF@<;hrL[kDNqL~>endstream
endobj
xref
0 10
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000629 00000 n
0000000697 00000 n
0000000982 00000 n
0000001041 00000 n
trailer
<<
/ID
[<ad0be0ae447f61082137085c690fb230><ad0be0ae447f61082137085c690fb230>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 10
>>
startxref
2073
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,62 @@
# QA: Agentic Chat — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the agentic-chat demo page
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
- [ ] Verify the background container (`data-testid="background-container"`) is visible
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
- [ ] Send a basic message (e.g. "Hello")
- [ ] Verify the agent responds with a text message
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Change background" suggestion button is visible
- [ ] Verify "Generate sonnet" suggestion button is visible
- [ ] Click the "Change background" suggestion
- [ ] Verify the suggestion either populates the input or sends the message
#### Background Change (useFrontendTool)
- [ ] Ask "Change the background to a sunset gradient"
- [ ] Verify the background container style changes from the default
- [ ] Verify the change_background tool returns a success status
#### Weather Render Tool (useRenderTool)
- [ ] Type "What's the weather in Tokyo?"
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
- [ ] City name displayed
- [ ] Temperature in degrees C
- [ ] Humidity percentage
- [ ] Wind speed in mph
- [ ] Conditions text
#### Agent Context
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Send a very long message and verify no UI breakage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Background changes are instant after tool execution
- Weather card renders with all data fields populated
- No UI errors or broken layouts
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-agent demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible (plan to go to Mars in 5 steps)
- [ ] Verify "Complex plan" suggestion button is visible (plan to make pizza in 10 steps)
#### Task Progress Tracker (useAgent with state streaming)
- [ ] Click "Simple plan" suggestion or type "Build a plan to go to Mars in 5 steps"
- [ ] Verify the TaskProgress component renders (`data-testid="task-progress"`)
- [ ] Verify the progress bar appears with a gradient fill
- [ ] Verify step items appear with descriptions (`data-testid="task-step-text"`)
- [ ] Verify the "N/N Complete" counter updates as steps complete
- [ ] Verify completed steps show:
- Green background gradient
- Check icon
- Green text color
- [ ] Verify the current pending step shows:
- Blue/purple background gradient
- Spinner icon with "Processing..." text
- Pulsing animation
- [ ] Verify future pending steps show:
- Gray background
- Clock icon
- Muted text color
#### Complex Plan
- [ ] Type "Plan to make pizza in 10 steps"
- [ ] Verify 10 steps appear in the progress tracker
- [ ] Verify progress bar width increases as steps complete
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Task progress tracker shows live step completion
- Progress bar animates smoothly
- No UI errors or broken layouts
@@ -0,0 +1,59 @@
# QA: Tool-Based Generative UI — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-tool-based demo page
- [ ] Verify the CopilotSidebar opens by default with title "Haiku Generator"
- [ ] Verify a placeholder haiku card is displayed on the main area
- [ ] Send a basic message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Nature Haiku" suggestion button is visible
- [ ] Verify "Ocean Haiku" suggestion button is visible
- [ ] Verify "Spring Haiku" suggestion button is visible
#### Haiku Generation (useFrontendTool)
- [ ] Click the "Nature Haiku" suggestion or type "Write me a haiku about nature"
- [ ] Verify a HaikuCard renders (`data-testid="haiku-card"`) with:
- [ ] Three Japanese text lines (`data-testid="haiku-japanese-line"`)
- [ ] Three English translation lines (`data-testid="haiku-english-line"`)
- [ ] A background gradient style applied to the card
- [ ] Verify the Japanese text contains actual Japanese characters (not Latin)
- [ ] Verify the English lines are a readable English translation
#### Image Display
- [ ] After haiku generation, verify an image renders (`data-testid="haiku-image"`) if the agent provides an image_name
- [ ] Verify the image src points to /images/ with a valid filename from the predefined list
#### Multiple Haikus
- [ ] Generate a second haiku (e.g. "Ocean Haiku")
- [ ] Verify the new haiku card appears at the top
- [ ] Verify the previous haiku card is still visible below
- [ ] Verify the initial placeholder haiku is removed
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Sidebar loads within 3 seconds
- Agent responds and generates haiku within 10 seconds
- Haiku cards display with Japanese and English text
- Generated haikus stack with newest on top
- No UI errors or broken layouts
@@ -0,0 +1,57 @@
# QA: Human in the Loop — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
- [ ] Verify the chat interface loads in a centered max-w-4xl container
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible
- [ ] Verify "Complex plan" suggestion button is visible
- [ ] Click the "Simple plan" suggestion
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
#### Step Selection and Approval
The HITL demo renders a single StepSelector card regardless of whether
the underlying flow uses an interrupt hook or a frontend HITL tool. The
framework-specific hook name is an implementation detail covered in each
package's demo source; QA below validates the user-visible card behavior.
- [ ] Send "Plan a trip to Mars in 5 steps"
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
- [ ] Verify step text is visible (`data-testid="step-text"`)
- [ ] Verify the selected count display shows "N/N selected"
- [ ] Toggle a step checkbox off and verify the count decreases
- [ ] Toggle it back on and verify the count increases
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
- [ ] Verify the agent continues processing after confirmation
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Step selector renders with toggleable checkboxes
- Accept/Reject flow completes without errors
- No UI errors or broken layouts
@@ -0,0 +1,61 @@
# QA: Shared State (Read + Write) — Microsoft Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible at `/demos/shared-state-read-write` on the dashboard host
- .NET agent backend is healthy (`/api/health`); `GitHubToken` (or equivalent) is set on the Railway deployment so the OpenAI client can authenticate; the `/shared-state-read-write` AG-UI endpoint is mounted in `agent/Program.cs`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/shared-state-read-write`; verify the page renders within 3s with the left sidebar (preferences + notes cards) and the right-side `CopilotChat` pane
- [ ] Verify `data-testid="preferences-card"` is visible with heading "Your preferences"
- [ ] Verify `data-testid="notes-card"` is visible with heading "Agent notes" and the empty-state `data-testid="notes-empty"` reading "No notes yet. Ask the agent to remember something."
- [ ] Verify the chat input placeholder is "Chat with the agent..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Greet me", "Remember something", "Plan a weekend"
- [ ] Send "Hello" and verify an assistant text response appears within 10s
### 2. Feature-Specific Checks
#### UI Writes -> Agent Reads (preferences via `agent.setState`)
- [ ] Type "Atai" into `data-testid="pref-name"`; verify `data-testid="pref-state-json"` updates to include `"name": "Atai"`
- [ ] Change `data-testid="pref-tone"` to `formal`; verify the JSON preview reflects `"tone": "formal"`
- [ ] Change `data-testid="pref-language"` to `Spanish`; verify the JSON preview reflects `"language": "Spanish"`
- [ ] Click the `Cooking` and `Travel` interest pills; verify both show the selected style (border `#BEC2FF`, bg `#BEC2FF1A`) and the JSON preview's `interests` array contains both entries
- [ ] Send "What do you know about me?"; verify within 10s the assistant reply references the name "Atai", a formal tone, Spanish, and the Cooking/Travel interests (the `SharedStateReadWriteAgent` injects them into the system prompt each turn)
- [ ] Click the "Plan a weekend" suggestion; verify the reply is tailored to the selected interests
#### Agent Writes -> UI Reads (notes via `set_notes` tool)
- [ ] Click the "Remember something" suggestion (sends "Remember that I prefer morning meetings and that I don't eat dairy.")
- [ ] Within 15s verify `data-testid="notes-list"` appears in the notes card and contains at least 2 `data-testid="note-item"` entries mentioning "morning meetings" and "dairy"
- [ ] Verify `data-testid="notes-empty"` is no longer rendered
- [ ] Send "Also remember I live in Berlin."; verify within 15s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract
#### UI Writes Back to Agent-Authored Slice (clear notes)
- [ ] With notes present, verify `data-testid="notes-clear-button"` is visible
- [ ] Click the Clear button; verify the notes list disappears and `data-testid="notes-empty"` re-renders
- [ ] Ask "What do you remember about me?"; verify the agent no longer cites the cleared notes (state was written back by the UI via `agent.setState({ notes: [] })`)
#### Multi-Turn State Persistence
- [ ] Change tone to `playful` and add the `Music` interest; send "Write me a one-line haiku greeting."; verify the reply is playful and references music
- [ ] Send a follow-up "Do it again in French."; verify the reply remains playful, switches to French, and still acknowledges the music interest — confirms preferences persist across turns without being re-sent
- [ ] Reload the page; verify preferences reset to defaults (`tone: casual`, `language: English`, empty interests, empty name) and notes reset to empty (state is per-session, seeded by the page's `useEffect`)
### 3. Error Handling
- [ ] Attempt to send an empty message; verify it is a no-op (no user bubble, no assistant response)
- [ ] Deselect all interests and clear the name; send "Who am I?"; verify the agent answers without crashing (the agent skips preference injection when the prefs object is empty)
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds; assistant text response within 10 seconds
- Preferences writes are reflected in `pref-state-json` synchronously on change
- Agent-authored notes appear in `notes-card` within 15 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls
- Clear button round-trips UI -> agent state and the agent loses access to the cleared notes on the next turn
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,75 @@
# QA: Shared State (Reading) — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-read demo page
- [ ] Verify the recipe card form loads (`data-testid="recipe-card"`)
- [ ] Verify the CopilotSidebar opens by default with title "AI Recipe Assistant"
- [ ] Send a message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Initial Recipe State
- [ ] Verify the recipe title input shows "Make Your Recipe"
- [ ] Verify the cooking time dropdown defaults to "45 min"
- [ ] Verify the skill level dropdown defaults to "Intermediate"
- [ ] Verify the default ingredients are displayed:
- [ ] Carrots (3 large, grated) with carrot emoji
- [ ] All-Purpose Flour (2 cups) with wheat emoji
- [ ] Verify the default instruction is displayed: "Preheat oven to 350 F"
#### Suggestions
- [ ] Verify "Create Italian recipe" suggestion is visible
- [ ] Verify "Make it healthier" suggestion is visible
- [ ] Verify "Suggest variations" suggestion is visible
#### Recipe Editing (Local State)
- [ ] Edit the recipe title and verify it updates
- [ ] Change the skill level dropdown and verify it updates
- [ ] Change the cooking time dropdown and verify it updates
- [ ] Toggle a dietary preference checkbox (e.g. "Vegetarian") and verify it's checked
- [ ] Click "+ Add Ingredient" (`data-testid="add-ingredient-button"`) and verify a new empty row appears
- [ ] Edit an ingredient name and amount
- [ ] Remove an ingredient by clicking the "x" button
- [ ] Click "+ Add Step" and verify a new instruction row appears
- [ ] Edit an instruction and verify it saves
- [ ] Remove an instruction by clicking the "x" button
#### AI-Powered Recipe Updates (useAgent with shared state)
- [ ] Click "Create Italian recipe" suggestion
- [ ] Verify the agent updates the recipe title, ingredients, and instructions
- [ ] Verify the ping indicator appears on changed sections
- [ ] Verify the "Improve with AI" button (`data-testid="improve-button"`) changes to "Please Wait..." while loading
- [ ] Click "Improve with AI" and verify the recipe is enhanced
#### Agent Reads Frontend State
- [ ] Edit the recipe (change title, add ingredients)
- [ ] Ask the agent "What recipe am I making?"
- [ ] Verify the agent's response references the current recipe state
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Verify the "Improve with AI" button is disabled while loading
## Expected Results
- Recipe card and sidebar load within 3 seconds
- Agent responds within 10 seconds
- Recipe state syncs bidirectionally between UI and agent
- Ping indicators highlight changed sections
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: State Streaming — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-streaming demo page
- [ ] Verify the chat interface loads with title "State Streaming"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: Shared State (Writing) — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-write demo page
- [ ] Verify the chat interface loads with title "Shared State (Writing)"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
@@ -0,0 +1,55 @@
# QA: Sub-Agents — Microsoft Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
- .NET agent backend is healthy (`/api/health`); `GitHubToken` (or equivalent) is set so the OpenAI client can authenticate; the `/subagents` AG-UI endpoint is mounted in `agent/Program.cs`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with the delegation log on the left and the `CopilotChat` pane on the right
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Delegation log" and an empty-state message reading "No delegations yet. Ask the supervisor to plan a deliverable."
- [ ] Verify the chat input placeholder is "Ask the supervisor to plan, draft, or critique..."
- [ ] Verify both suggestion pills are visible with verbatim titles: "Quick brief", "Marketing post"
- [ ] Send "Hello! What can you do?"; verify an assistant reply appears within 10s describing the three sub-agents
### 2. Feature-Specific Checks
#### Single Sub-Agent Delegation
- [ ] Send "Use the research sub-agent to give me 3 facts about pair programming."; within 30s verify a `data-testid="delegation-entry"` is rendered with `data-status="completed"`, the "Research" badge, and a non-empty result body containing 3 bullet-style lines
- [ ] Verify the assistant chat reply summarizes the research result in one or two short sentences
#### Live Running -> Completed Transition
- [ ] Click the "Quick brief" suggestion; immediately observe the delegation log
- [ ] Verify at least one `data-testid="delegation-entry"` appears with `data-status="running"`, the spinner is visible, and the "Sub-agent is working…" message is shown
- [ ] Within 60s verify the same entry transitions to `data-status="completed"` and the spinner is replaced with the actual result text
- [ ] Verify subsequent delegations append additional entries (the list grows; earlier entries are not removed)
#### Sequential Pipeline (Research -> Write -> Critique)
- [ ] Click the "Marketing post" suggestion; within 90s verify the delegation log contains at least 3 `data-testid="delegation-entry"` rows in this order: Research, Writing, Critique
- [ ] Verify each entry's `data-status` ends as `completed` (no `failed` rows under normal conditions)
- [ ] Verify the assistant chat reply concisely summarizes the work and references the critique output
#### Failure Path (Optional / Best Effort)
- [ ] If you can intentionally provoke an upstream failure (e.g. revoke the API key briefly, or disable network egress for the .NET backend container), trigger a delegation; verify the entry's `data-status="failed"` and the failure message is rendered in red
- [ ] Verify the assistant surfaces the failure briefly to the user instead of fabricating a result
### 3. Error Handling
- [ ] Send an empty message; verify it is a no-op (no user bubble, no assistant response, no new delegation entries)
- [ ] Send a non-task message ("hi"); verify the agent responds without invoking any sub-agent (no new delegation entries appear)
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds
- Single-sub-agent delegations complete within 30 seconds; full Research -> Write -> Critique chains complete within 90 seconds
- Each delegation row shows a visible `running` -> `completed` (or `failed`) transition; the running spinner is rendered while the secondary chat-client call is in flight
- Failed sub-agent calls are reported as `failed` rows with a structured error message — never silently turned into `completed` rows with garbage results
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,61 @@
# QA: Tool Rendering — MS Agent Framework (.NET)
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the tool-rendering demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Weather in San Francisco" suggestion button is visible
- [ ] Verify "Weather in New York" suggestion button is visible
- [ ] Verify "Weather in Tokyo" suggestion button is visible
- [ ] Click a weather suggestion and verify it populates the input or sends the message
#### Weather Card Rendering (useRenderTool)
- [ ] Type "What's the weather in San Francisco?"
- [ ] Verify loading state shows "Retrieving weather..." with a spinner
- [ ] Verify the WeatherCard renders (`data-testid="weather-card"`) with:
- [ ] City name displayed (`data-testid="weather-city"`)
- [ ] Temperature in both Celsius and Fahrenheit
- [ ] Humidity percentage (`data-testid="weather-humidity"`)
- [ ] Wind speed in mph (`data-testid="weather-wind"`)
- [ ] Feels-like temperature (`data-testid="weather-feels-like"`)
- [ ] Conditions text with appropriate weather icon (sun/rain/cloud)
- [ ] Verify the card background color matches the weather condition theme:
- Clear/Sunny: #667eea (blue-purple)
- Rain/Storm: #4A5568 (dark gray)
- Cloudy: #718096 (medium gray)
- Snow: #63B3ED (light blue)
#### Multiple Weather Queries
- [ ] Ask about weather in a second city
- [ ] Verify a second WeatherCard renders without breaking the first
- [ ] Verify each card shows the correct city name
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Weather cards render with all data fields populated
- Weather icon and theme color match the conditions
- No UI errors or broken layouts
@@ -0,0 +1,2 @@
# This integration uses a .NET agent backend — no Python dependencies required.
# See agent/ProverbsAgent.csproj for .NET package references.
@@ -0,0 +1,51 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
// endpoint (mirroring the LangGraph reference) lets us set
// `a2ui.injectA2UITool: false` — the backend .NET agent owns the
// `search_flights` tool which emits its own `a2ui_operations` container.
//
// The .NET backend exposes this agent at `AGENT_URL/a2ui-fixed-schema`
// (see agent/Program.cs).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const a2uiFixedSchemaAgent = new HttpAgent({
url: `${AGENT_URL}/a2ui-fixed-schema`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend emits its own `a2ui_operations` container via the
// `search_flights` tool. We still run the A2UI middleware so it detects
// the container in tool results and forwards surfaces to the frontend —
// but we do NOT inject a runtime `render_a2ui` tool on top of the
// agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,53 @@
// Dedicated runtime for the /demos/agent-config cell.
//
// Proxies to the .NET backend's `/agent-config` endpoint, where a dedicated
// agent reads the forwarded tone / expertise / responseLength triple from
// shared state and rebuilds its system prompt per turn.
//
// References:
// - showcase/integrations/ms-agent-dotnet/agent/AgentConfigAgent.cs
// - showcase/integrations/ms-agent-dotnet/src/app/demos/agent-config/page.tsx
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/agent-config` });
}
// The page mounts <CopilotKit agent="agent-config-demo" />; resolve that to
// the dedicated agent. `default` aliased so any internal default-agent
// lookups resolve against the same agent.
const agents: Record<string, AbstractAgent> = {
"agent-config-demo": createAgent(),
default: createAgent(),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records;
// fixed in source, pending release.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const err = error as Error;
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and can short-circuit the
// request by throwing a Response. We validate a static `Authorization: Bearer
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
// agent.
//
// Implementation note: the V1 Next.js adapter
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
// framework-agnostic fetch handler that returns a plain
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
// App Router route export.
//
// References:
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
// Reuse the neutral SalesAgent for the authenticated path. The point of this
// demo is the gate mechanism, not per-user agent branching — authenticated
// users get the same behavior as any other neutral demo.
const runtime = new CopilotRuntime({
agents: {
"auth-demo": createAgent(),
// Fallback: useAgent() with no args resolves "default" — alias to the
// same agent so hooks in the demo page resolve cleanly.
default: createAgent(),
},
});
const BASE_PATH = "/api/copilotkit-auth";
// Framework-agnostic fetch handler with the auth gate wired up.
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
// Throwing a Response short-circuits the pipeline. The runtime maps
// thrown Responses to the HTTP response verbatim (status + body).
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
// Next.js App Router bindings. The handler is framework-agnostic — it takes
// a web Request and returns a web Response — so it drops straight into the
// POST/GET exports without any adapter shim.
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,77 @@
// Dedicated runtime for the Beautiful Chat flagship cell.
//
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
// Open Generative UI, and MCP Apps. Those three flags are set on the
// CopilotRuntime itself (not on the backing agent), so we scope this cell
// to its own route instead of bleeding flags into the shared
// `/api/copilotkit` endpoint used by every other cell.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
// - ../copilotkit-multimodal/route.ts (dedicated-route pattern)
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
// Points at the `/beautiful-chat` mount on the .NET backend
// (Program.cs: `app.MapAGUI("/beautiful-chat", ...)`).
return new HttpAgent({ url: `${AGENT_URL}/beautiful-chat` });
}
const agents: Record<string, AbstractAgent> = {
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
"beautiful-chat": createAgent(),
// Alias for internal components that call `useAgent()` without args.
default: createAgent(),
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: true,
a2ui: {
// The .NET agent has its own `generate_a2ui` tool; don't inject the
// runtime's default A2UI tool on top.
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "copilotkit://app-dashboard-catalog",
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
serverId: "beautiful_chat_mcp",
},
],
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,59 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
// cell. Splitting into its own endpoint (mirroring the LangGraph reference)
// lets us set `a2ui.injectA2UITool: false` — the backend .NET agent owns the
// `generate_a2ui` tool itself, so double-binding from the runtime would
// duplicate the tool slot and confuse the LLM.
//
// The .NET backend exposes this agent at `AGENT_URL/declarative-gen-ui`
// (see agent/Program.cs).
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeGenUiAgent = new HttpAgent({
url: `${AGENT_URL}/declarative-gen-ui`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
a2ui: {
// The backend agent owns the `generate_a2ui` tool explicitly, so the
// runtime MUST NOT auto-inject its own A2UI tool on top. The A2UI
// middleware still runs — it serialises the registered client catalog
// into the agent's `copilotkit.context` so the secondary LLM inside
// `generate_a2ui` knows which components to emit — and it still detects
// the `a2ui_operations` container in the tool result and streams
// rendered surfaces to the frontend.
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,52 @@
// Dedicated runtime for the declarative-hashbrown demo.
//
// The demo page (`src/app/demos/declarative-hashbrown/page.tsx`) wraps
// CopilotChat in the HashBrownDashboard provider and overrides the
// assistant message slot with a renderer that consumes hashbrown-shaped
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
// The MS Agent behind this endpoint (see `src/agents/byoc_hashbrown_agent.py`,
// mounted at `/byoc-hashbrown` in `agent_server.py`) has a system prompt
// tuned to emit that shape. The legacy `byoc_hashbrown` Python module name
// is retained (mirrors LangGraph's convention — see
// `langgraph-python/src/app/api/copilotkit-declarative-hashbrown/route.ts`);
// only the user-facing slug, route, and frontend folder use the
// `declarative-` prefix so the manifest is one-to-one with LGP.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: { "declarative-hashbrown-demo": declarativeHashbrownAgent },
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,48 @@
// Dedicated runtime for the declarative-json-render demo.
//
// The demo page (`src/app/demos/declarative-json-render/page.tsx`) swaps
// in `JsonRenderAssistantMessage` and renders an agent-emitted JSON spec
// via `@json-render/react` against a Zod-validated catalog (MetricCard,
// BarChart, PieChart). The MS Agent behind this endpoint (see
// `src/agents/byoc_json_render_agent.py`, mounted at `/byoc-json-render`
// in `agent_server.py`) emits that JSON envelope. The legacy
// `byoc_json_render` Python module name is retained (matches LGP's
// convention); only the slug, route, and frontend folder use the
// `declarative-` prefix.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see hashbrown route
agents: { byoc_json_render: declarativeJsonRenderAgent },
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// CopilotKit runtime for the MCP Apps cell (MS Agent Framework backend).
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
// renders in the chat as a sandboxed iframe.
//
// The optional catch-all route mirrors the langgraph-python north star. MCP
// Apps resource proxy requests are addressed below `/api/copilotkit-mcp-apps`,
// so a plain `route.ts` at the parent segment handles the chat POST but misses
// those subpath requests.
//
// Reference:
// https://docs.copilotkit.ai/integrations/microsoft-agent-framework/generative-ui/mcp-apps
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
// headless-complete shares this runtime because its cell also exercises
// MCP Apps activity rendering (the "Sketch a diagram" pill exercises the
// Excalidraw MCP server via the same middleware). The catch-all `/` agent
// on the Python backend backs it -- no dedicated headless endpoint.
const headlessCompleteAgent = new HttpAgent({
url: `${AGENT_URL}/headless-complete`,
});
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents: {
"mcp-apps": mcpAppsAgent,
"headless-complete": headlessCompleteAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Keep the server id 1:1 with langgraph-python so persisted MCP Apps
// and fixture-backed resource calls use the same identity.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,63 @@
// Dedicated runtime for the Multimodal Attachments demo.
//
// Why its own route? The backing agent (MultimodalAgent, mounted at
// `/multimodal` by the .NET backend) is the only one in this showcase that
// expects vision content parts. Registering it under the shared
// `/api/copilotkit` runtime would silently route every other cell through the
// vision endpoint too. A dedicated route keeps the vision capability scoped
// to exactly the cell that exercises it, matching the LangGraph reference's
// pattern for `/api/copilotkit-multimodal`.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"`.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent, type RunAgentInput } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
class DotnetMultimodalHttpAgent extends HttpAgent {
run(input: RunAgentInput): ReturnType<HttpAgent["run"]> {
return super.run(input);
}
protected requestInit(input: RunAgentInput): RequestInit {
return super.requestInit(input);
}
}
function createAgent() {
// Points at the `/multimodal` mount on the .NET backend (Program.cs:
// `app.MapAGUI("/multimodal", ...)`).
return new DotnetMultimodalHttpAgent({ url: `${AGENT_URL}/multimodal` });
}
const agents: Record<string, AbstractAgent> = {
"multimodal-demo": createAgent(),
default: createAgent(),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import {
AbstractAgent,
HttpAgent,
type BaseEvent,
type RunAgentInput,
} from "@ag-ui/client";
import { map, type Observable } from "rxjs";
// Dedicated runtime for the Open Generative UI demos, mirroring the
// LangGraph-Python `copilotkit-ogui` route.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
//
// Each agent name proxies to a separate `MapAGUI` endpoint on the .NET
// backend (see `agent/Program.cs`).
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const OGUI_TOOL_CALL_ID_SUFFIX = /__ogui_run_[0-9a-f-]+$/i;
console.log("[copilotkit-ogui/route] Initializing OGUI CopilotKit runtime");
console.log(`[copilotkit-ogui/route] AGENT_URL: ${AGENT_URL}`);
function stripOguiToolCallId(id: string): string {
return id.replace(OGUI_TOOL_CALL_ID_SUFFIX, "");
}
function makeOguiToolCallId(id: string, runId: string): string {
return `${stripOguiToolCallId(id)}__ogui_run_${runId}`;
}
function stripOguiToolCallIdsFromMessage(message: unknown): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
next.toolCallId = stripOguiToolCallId(next.toolCallId);
changed = true;
}
if (typeof next.tool_call_id === "string") {
next.tool_call_id = stripOguiToolCallId(next.tool_call_id);
changed = true;
}
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map((toolCall) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
call.id = stripOguiToolCallId(call.id);
}
return call;
});
changed = true;
}
return changed ? next : message;
}
class OpenGenUiHttpAgent extends HttpAgent {
run(input: RunAgentInput): Observable<BaseEvent> {
const toolCallIds = new Map<string, string>();
const runId = input.runId;
const sanitizedInput = {
...input,
messages: input.messages.map(stripOguiToolCallIdsFromMessage),
} as RunAgentInput;
return super.run(sanitizedInput).pipe(
map((event: BaseEvent) => {
const e = event as BaseEvent & {
toolCallId?: string;
toolCallName?: string;
};
if (
(e.type === "TOOL_CALL_START" || e.type === "TOOL_CALL_CHUNK") &&
e.toolCallName === "generateSandboxedUi" &&
e.toolCallId
) {
const originalId = stripOguiToolCallId(e.toolCallId);
const rewrittenId = makeOguiToolCallId(originalId, runId);
toolCallIds.set(originalId, rewrittenId);
return { ...event, toolCallId: rewrittenId };
}
if (e.toolCallId) {
const originalId = stripOguiToolCallId(e.toolCallId);
const rewrittenId = toolCallIds.get(originalId);
if (rewrittenId) {
return { ...event, toolCallId: rewrittenId };
}
}
return event;
}),
);
}
}
const openGenUiAgent = new OpenGenUiHttpAgent({
url: `${AGENT_URL}/open-gen-ui`,
});
const openGenUiAdvancedAgent = new OpenGenUiHttpAgent({
url: `${AGENT_URL}/open-gen-ui-advanced`,
});
const agents: Record<string, AbstractAgent> = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// Server-side config is identical for the minimal and advanced cells —
// the advanced behaviour (sandbox -> host function calls) is wired
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
// the provider. The single `openGenerativeUI` flag below turns on
// Open Generative UI for the listed agent(s); the runtime middleware
// converts each agent's streamed `generateSandboxedUi` tool call into
// `open-generative-ui` activity events.
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
return await handleRequest(req);
} catch (error: unknown) {
const err = error as Error;
console.error(`[copilotkit-ogui/route] ERROR: ${err.message}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,107 @@
// Dedicated runtime for the /demos/voice cell (MS Agent Python).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Point at the tool-free /voice endpoint so aimock returns a direct text
// response instead of a tool call that the agent can't summarize.
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/voice/` });
/**
* Transcription service wrapper that reports a clean, typed auth error when
* OPENAI_API_KEY is not configured. When the key is present we delegate to
* the real OpenAI-backed service; any upstream Whisper error keeps its
* natural categorization.
*
* Note: We pin `baseURL` to real OpenAI (or `OPENAI_TRANSCRIPTION_BASE_URL`
* when explicitly set) instead of falling through to `OPENAI_BASE_URL`. In
* local docker / Railway preview environments `OPENAI_BASE_URL` points at
* aimock so LLM completions stay deterministic, but aimock has a catchall
* `endpoint: "transcription"` fixture that would otherwise intercept every
* real mic recording and return the canned "What is the weather in Tokyo?"
* phrase regardless of what the user actually said — and on production
* aimock's transcription proxy returns a 502 "Invalid file format" before
* any phrase reaches the user. The sample-audio button is the deterministic
* affordance (synchronous text injection); the mic is the only path that
* should exercise real Whisper.
*
* Mirrors langgraph-python's voice route exactly.
*/
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
const baseURL =
process.env.OPENAI_TRANSCRIPTION_BASE_URL ?? "https://api.openai.com/v1";
this.delegate = apiKey
? new TranscriptionServiceOpenAI({
openai: new OpenAI({ apiKey, baseURL }),
})
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,830 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent, BaseEvent } from "@ag-ui/client";
import { EventType, FunctionMiddleware, HttpAgent } from "@ag-ui/client";
import { Observable } from "rxjs";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const REPLAY_SAFE_TOOL_CALL_ID_SUFFIX = /__ck_run_[0-9a-f-]+$/i;
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(path = "/") {
const agent = new HttpAgent({ url: `${AGENT_URL}${path}` });
// Universal strip middleware (no decision-suffix). Runs as the first
// registered middleware so EVERY outbound request carries clean
// canonical toolCallIds (the replay-safe `__ck_run_<uuid>` suffix is
// removed) before any agent-specific middleware sees the input.
// Decision suffixing (`__approved` / `__rejected` / `__cancelled`)
// is intentionally NOT applied here — only `createReplaySafeAgent`
// does that, because the suffix is non-idempotent and the inner
// replay-safe middleware re-runs the same logic after this one.
agent.use(
new FunctionMiddleware((input, next) => {
return next.run({
...input,
messages: (input.messages ?? []).map(
stripReplaySafeToolCallIdsFromMessage,
),
});
}),
);
return agent;
}
function stripReplaySafeToolCallId(id: string): string {
return id.replace(REPLAY_SAFE_TOOL_CALL_ID_SUFFIX, "");
}
function makeReplaySafeToolCallId(id: string, runId: string): string {
return `${stripReplaySafeToolCallId(id)}__ck_run_${runId}`;
}
function stripReplaySafeToolCallIdsFromMessage(message: unknown): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
next.toolCallId = stripReplaySafeToolCallId(next.toolCallId);
changed = true;
}
if (typeof next.tool_call_id === "string") {
next.tool_call_id = stripReplaySafeToolCallId(next.tool_call_id);
changed = true;
}
// Strip on BOTH the camelCase (AG-UI canonical) and snake_case (OpenAI
// wire format) tool-call arrays. Some runtimes / message converters
// pass the OpenAI shape through unchanged; without this branch the
// replay-safe `__ck_run_<uuid>` suffix slips through to aimock and
// toolCallId-keyed fixtures fail to match on follow-up turns.
const stripToolCallArrayEntry = (toolCall: unknown) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
call.id = stripReplaySafeToolCallId(call.id);
}
// OpenAI nests the tool_call_id under `function` in some shapes; strip
// there too just to keep the surface clean before aimock sees it.
if (call.function && typeof call.function === "object") {
const fn = { ...(call.function as Record<string, unknown>) };
if (typeof fn.tool_call_id === "string") {
fn.tool_call_id = stripReplaySafeToolCallId(fn.tool_call_id);
call.function = fn;
}
}
return call;
};
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map(stripToolCallArrayEntry);
changed = true;
}
if (Array.isArray(next.tool_calls)) {
next.tool_calls = next.tool_calls.map(stripToolCallArrayEntry);
changed = true;
}
return changed ? next : message;
}
function textFromMessageContent(content: unknown): string | undefined {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return undefined;
const text = content
.map((part) => {
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
return text || undefined;
}
function textFromContextValue(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (value === undefined || value === null) return undefined;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function buildContextSystemMessage(context: unknown): string | undefined {
if (!Array.isArray(context) || context.length === 0) return undefined;
const lines = ["## Context from the application"];
for (const entry of context) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
const description =
typeof record.description === "string" ? record.description : undefined;
const value = textFromContextValue(record.value);
if (!description || !value) continue;
lines.push("", description, value);
}
return lines.length > 1 ? lines.join("\n") : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
}
function buildSharedStateReadWriteSystemMessage(state: unknown): string {
const stateRecord = readRecord(state);
const prefs = readRecord(stateRecord?.preferences) ?? {};
const name = typeof prefs.name === "string" ? prefs.name : "";
const tone = typeof prefs.tone === "string" ? prefs.tone : "casual";
const language =
typeof prefs.language === "string" ? prefs.language : "English";
const interests = Array.isArray(prefs.interests)
? prefs.interests.filter(
(interest): interest is string => typeof interest === "string",
)
: [];
return [
"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.",
"",
"[shared-state-read-write] preferences:",
"{",
` "name": ${JSON.stringify(name)},`,
` "tone": ${JSON.stringify(tone)},`,
` "language": ${JSON.stringify(language)},`,
` "interests": ${JSON.stringify(interests)}`,
"}",
"Tailor every response to these preferences. Address the user by name when appropriate.",
].join("\n");
}
type ToolResultDecision = "approved" | "rejected" | "cancelled";
function toolDecisionFromContent(
content: unknown,
): ToolResultDecision | undefined {
const text = textFromMessageContent(content);
if (!text) return undefined;
try {
const parsed = JSON.parse(text);
if (parsed?.approved === true || parsed?.accepted === true) {
return "approved";
}
if (parsed?.approved === false || parsed?.accepted === false) {
return "rejected";
}
if (parsed?.cancelled === true || parsed?.canceled === true) {
return "cancelled";
}
} catch {
const normalized = text.toLowerCase();
if (
(normalized.includes("cancelled") || normalized.includes("canceled")) &&
(normalized.includes("not scheduled") ||
normalized.includes("not booked") ||
normalized.includes("no time"))
) {
return "cancelled";
}
}
return undefined;
}
function makeDecisionToolCallId(id: string, decision: ToolResultDecision) {
return `${id}__${decision}`;
}
function applyToolResultDecisionSuffix(
message: unknown,
decisionsByToolCallId: Map<string, ToolResultDecision>,
): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
const decision = decisionsByToolCallId.get(next.toolCallId);
if (decision) {
next.toolCallId = makeDecisionToolCallId(next.toolCallId, decision);
changed = true;
}
}
if (typeof next.tool_call_id === "string") {
const decision = decisionsByToolCallId.get(next.tool_call_id);
if (decision) {
next.tool_call_id = makeDecisionToolCallId(next.tool_call_id, decision);
changed = true;
}
}
const suffixArrayEntry = (toolCall: unknown) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
const decision = decisionsByToolCallId.get(call.id);
if (decision) {
call.id = makeDecisionToolCallId(call.id, decision);
}
}
return call;
};
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map(suffixArrayEntry);
changed = true;
}
if (Array.isArray(next.tool_calls)) {
next.tool_calls = next.tool_calls.map(suffixArrayEntry);
changed = true;
}
return changed ? next : message;
}
function messageRole(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined;
const role = (message as Record<string, unknown>).role;
return typeof role === "string" ? role : undefined;
}
function hasTextContent(message: Record<string, unknown>): boolean {
const content = textFromMessageContent(message.content);
return Boolean(content?.trim());
}
function dropStaleToolInteractionsBeforeLatestUser(messages: unknown[]) {
const latestUserIndex = messages.findLastIndex(
(message) => messageRole(message) === "user",
);
if (latestUserIndex < 0 || latestUserIndex !== messages.length - 1) {
return messages;
}
let changed = false;
const nextMessages: unknown[] = [];
messages.forEach((message, index) => {
if (index >= latestUserIndex || !message || typeof message !== "object") {
nextMessages.push(message);
return;
}
const record = message as Record<string, unknown>;
if (record.role === "tool") {
changed = true;
return;
}
if (
record.role === "assistant" &&
(Array.isArray(record.toolCalls) || Array.isArray(record.tool_calls))
) {
const next = { ...record };
delete next.toolCalls;
delete next.tool_calls;
changed = true;
if (hasTextContent(next)) {
nextMessages.push(next);
}
return;
}
nextMessages.push(message);
});
return changed ? nextMessages : messages;
}
function prepareReplaySafeMessages(messages: unknown[] = []) {
const stripped = messages.map(stripReplaySafeToolCallIdsFromMessage);
const decisionsByToolCallId = new Map<string, ToolResultDecision>();
for (const message of stripped) {
if (!message || typeof message !== "object") continue;
const record = message as Record<string, unknown>;
const toolCallId =
typeof record.tool_call_id === "string"
? record.tool_call_id
: typeof record.toolCallId === "string"
? record.toolCallId
: undefined;
if (record.role !== "tool" || !toolCallId) {
continue;
}
const decision = toolDecisionFromContent(record.content);
if (decision) {
decisionsByToolCallId.set(toolCallId, decision);
}
}
const decisionAwareMessages =
decisionsByToolCallId.size === 0
? stripped
: stripped.map((message) =>
applyToolResultDecisionSuffix(message, decisionsByToolCallId),
);
return dropStaleToolInteractionsBeforeLatestUser(decisionAwareMessages);
}
function createReplaySafeAgent(path: string, replaySafeToolNames: string[]) {
const agent = createAgent(path);
const replaySafeTools = new Set(replaySafeToolNames);
agent.use(
new FunctionMiddleware((input, next) => {
return new Observable<BaseEvent>((subscriber) => {
const toolCallIds = new Map<string, string>();
const sanitizedInput = {
...input,
messages: prepareReplaySafeMessages(input.messages),
};
const subscription = next.run(sanitizedInput).subscribe({
next(event) {
const e = event as BaseEvent & {
toolCallId?: string;
toolCallName?: string;
};
if (
(e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_CHUNK) &&
e.toolCallName &&
replaySafeTools.has(e.toolCallName) &&
e.toolCallId
) {
const originalId = stripReplaySafeToolCallId(e.toolCallId);
const rewrittenId = makeReplaySafeToolCallId(
originalId,
input.runId,
);
toolCallIds.set(originalId, rewrittenId);
subscriber.next({ ...event, toolCallId: rewrittenId });
return;
}
if (e.toolCallId) {
const originalId = stripReplaySafeToolCallId(e.toolCallId);
const rewrittenId = toolCallIds.get(originalId);
if (rewrittenId) {
subscriber.next({ ...event, toolCallId: rewrittenId });
return;
}
}
subscriber.next(event);
},
error(error) {
subscriber.error(error);
},
complete() {
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
function createGenUiAgent() {
const agent = createAgent("/gen-ui-agent");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on inbound messages, so this middleware only needs to wire the
// gen-ui-agent's STATE_SNAPSHOT bridging from set_steps tool args.
return new Observable<BaseEvent>((subscriber) => {
const setStepsToolCallIds = new Set<string>();
const argsByToolCallId = new Map<string, string>();
const emitStateSnapshotFromArgs = (toolCallId: string) => {
const args = argsByToolCallId.get(toolCallId);
if (!args) return;
try {
const parsed = JSON.parse(args);
if (!Array.isArray(parsed?.steps)) return;
subscriber.next({
type: EventType.STATE_SNAPSHOT,
snapshot: { steps: parsed.steps },
} as BaseEvent);
} catch {
// Args may arrive in chunks; wait until the buffered JSON is whole.
}
};
const subscription = next.run(input).subscribe({
next(event) {
if (
event.type === EventType.TOOL_CALL_START &&
(event as { toolCallName?: string }).toolCallName === "set_steps"
) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId) setStepsToolCallIds.add(toolCallId);
return;
}
if (event.type === EventType.TOOL_CALL_ARGS) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
const delta = String((event as { delta?: string }).delta ?? "");
argsByToolCallId.set(
toolCallId,
`${argsByToolCallId.get(toolCallId) ?? ""}${delta}`,
);
emitStateSnapshotFromArgs(toolCallId);
return;
}
}
if (
event.type === EventType.TOOL_CALL_END ||
event.type === EventType.TOOL_CALL_RESULT
) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
if (event.type === EventType.TOOL_CALL_RESULT) {
setStepsToolCallIds.delete(toolCallId);
argsByToolCallId.delete(toolCallId);
}
return;
}
}
subscriber.next(event);
},
error(error) {
subscriber.error(error);
},
complete() {
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
function createReadonlyContextAgent() {
const agent = createAgent("/readonly-state-agent-context");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on `input.messages`, so the injected system message rides along
// with already-canonicalised toolCallIds.
const contextMessage = buildContextSystemMessage(
(input as { context?: unknown }).context,
);
if (!contextMessage) {
return next.run(input);
}
return next.run({
...input,
messages: [
{
id: `${input.runId ?? crypto.randomUUID()}-app-context`,
role: "system",
content: contextMessage,
},
...(input.messages ?? []),
],
});
}),
);
return agent;
}
function createSharedStateReadWriteAgent() {
const agent = createAgent("/shared-state-read-write");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on `input.messages`; the injected shared-state system message
// rides along with already-canonicalised toolCallIds.
return next.run({
...input,
messages: [
{
id: `${input.runId ?? crypto.randomUUID()}-shared-state`,
role: "system",
content: buildSharedStateReadWriteSystemMessage(
(input as { state?: unknown }).state,
),
},
...(input.messages ?? []),
],
});
}),
);
return agent;
}
function createReasoningAgent(path = "/reasoning") {
const agent = createAgent(path);
// Microsoft.Agents.AI.Hosting.AGUI.AspNetCore@1.0.0-preview.251110.1
// does not emit AG-UI REASONING_MESSAGE_* events yet. Keep the backend
// behavior intact, but add the reasoning-role stream shape CopilotKit's
// v2 chat slots expect. Also strip replayed reasoning messages before
// sending follow-up turns back to the .NET AG-UI host, whose input mapper
// only accepts user/assistant/system/tool roles.
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on inbound messages. Here we additionally drop reasoning-role
// messages because the .NET AG-UI host's input mapper rejects
// them (it only accepts user/assistant/system/tool roles).
const sanitizedInput = {
...input,
messages: input.messages?.filter(
(message) => message.role !== "reasoning",
),
};
const reasoningMessageId = `${input.runId ?? crypto.randomUUID()}-reasoning`;
const reasoningDelta =
"I am checking the request, choosing the relevant tool or answer path, and then summarizing the result.";
return new Observable<BaseEvent>((subscriber) => {
let injected = false;
const injectReasoning = () => {
if (injected) return;
injected = true;
subscriber.next({
type: EventType.REASONING_START,
messageId: reasoningMessageId,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta: reasoningDelta,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_END,
messageId: reasoningMessageId,
} as BaseEvent);
};
const subscription = next.run(sanitizedInput).subscribe({
next(event) {
subscriber.next(event);
if (event.type === EventType.RUN_STARTED) {
injectReasoning();
}
},
error(error) {
subscriber.error(error);
},
complete() {
injectReasoning();
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
// Register the same agent under all names used by demo pages.
const agentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"shared-state-read",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
"frontend-tools",
"frontend-tools-async",
// Aliases for ADK/LGP-style underscore names (frontend pages use these).
"frontend_tools",
"frontend_tools_async",
];
// Agent names routed to the interrupt-adapted scheduling backend. Both
// gen-ui-interrupt and interrupt-headless share the same MS Agent Framework
// scheduling agent; only the frontend UX differs (inline in chat vs. external
// popup driven from a button grid).
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
agents["human_in_the_loop"] = createReplaySafeAgent("/", [
"generate_task_steps",
]);
agents["headless-complete"] = createAgent("/headless-complete");
// Interrupt-adapted demos — frontend-tool shim for LangGraph `interrupt()`.
// Both gen-ui-interrupt and interrupt-headless share the same scheduling agent;
// only the frontend UX differs (inline time-picker vs. external popup).
for (const name of interruptAgentNames) {
agents[name] = createReplaySafeAgent("/interrupt-adapted", [
"schedule_meeting",
]);
}
// In-App HITL -- async frontend-tool + app-level modal (outside chat).
// Dedicated hitl-in-app agent mounted at /hitl-in-app on the FastAPI
// backend; agent has tools=[] and relies on the frontend-provided
// `request_user_approval` tool injected by CopilotKit at request time.
agents["hitl-in-app"] = createReplaySafeAgent("/hitl-in-app", [
"request_user_approval",
]);
// In-Chat HITL -- frontend-defined `book_call` tool rendered inline in the
// chat via `useHumanInTheLoop`. Backend agent has tools=[] and routes to
// /hitl-in-chat on the FastAPI backend.
agents["hitl-in-chat"] = createReplaySafeAgent("/hitl-in-chat", ["book_call"]);
// Generative UI Agent — backend with `set_steps` tool + `steps` state
// schema mirrored from LGP's gen_ui_agent. The frontend renders a live
// progress card subscribed to `agent.state.steps`.
agents["gen-ui-agent"] = createGenUiAgent();
// Tool-Based Generative UI -- frontend registers `render_bar_chart` and
// `render_pie_chart` via `useComponent`; backend agent has tools=[] and a
// system prompt that picks the right chart type for the user's request.
agents["gen-ui-tool-based"] = createAgent("/gen-ui-tool-based");
// Shared State (Streaming) — `write_document` tool with `predict_state_config`
// that streams the tool's `document` arg into `state.document` per-token.
// See `src/agents/shared_state_streaming.py`.
agents["shared-state-streaming"] = createAgent("/shared-state-streaming");
// Readonly state via `useAgentContext` — minimal agent, no tools, reads
// frontend-provided context entries on every turn.
agents["readonly-state-agent-context"] = createReadonlyContextAgent();
// Shared State (Read + Write) — bidirectional state via state_schema +
// state_update. Backend exposes a dedicated agent at /shared-state-read-write
// with `preferences` + `notes` slots; UI writes preferences via setState,
// agent writes notes via the `set_notes` tool.
agents["shared-state-read-write"] = createSharedStateReadWriteAgent();
// Sub-Agents — supervisor agent at /subagents that delegates to research /
// writing / critique sub-agents and surfaces a live `delegations` log to the
// UI via shared state.
agents["subagents"] = createAgent("/subagents");
agents["default"] = createAgent();
// Tool-rendering demos — share the dedicated reasoning-chain agent
// mounted at /tool-rendering-reasoning-chain on the Python backend. All
// three cells call the same agent; they differ only in how the frontend
// renders tool calls.
// Reasoning cells (`reasoning-default` + `reasoning-custom`) share a
// dedicated backend mounted at `/reasoning` that uses the OpenAI Responses
// API (gpt-5/o-series) — the only chat client that emits AG-UI
// `REASONING_MESSAGE_*` events. See `src/agents/reasoning_agent.py`.
agents["reasoning-default"] = createReasoningAgent("/reasoning");
agents["reasoning-custom"] = createReasoningAgent("/reasoning");
// Tool-rendering demos — the plain `tool-rendering` cell and the two
// catchall variants share a non-reasoning backend (mounted at
// `/tool-rendering`). The reasoning-chain cell has its own dedicated
// backend (mounted at `/tool-rendering-reasoning-chain`) that routes
// through OpenAI's Responses API for reasoning streaming; mixing
// reasoning blocks into the catchall renderers breaks the
// default-catchall cell's spec.
agents["tool-rendering"] = createAgent("/tool-rendering");
agents["tool-rendering-default-catchall"] = createAgent("/tool-rendering");
agents["tool-rendering-custom-catchall"] = createAgent("/tool-rendering");
agents["tool-rendering-reasoning-chain"] = createReasoningAgent(
"/tool-rendering-reasoning-chain",
);
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
const err = error as Error;
console.error(`[copilotkit/route] ERROR: ${err.message}`);
console.error(`[copilotkit/route] Stack: ${err.stack}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "ms-agent-dotnet",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "ms-agent-dotnet",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "ms-agent-dotnet";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,21 @@
// Coerces a tool-call result into a typed object. The .NET AG-UI adapter can
// wrap JSON string tool results in another JSON string, so parse a few layers.
export function parseJsonResult<T>(result: unknown): T {
let parsed = result;
for (let depth = 0; depth < 3 && typeof parsed === "string"; depth += 1) {
if (!parsed.trim()) return {} as T;
try {
parsed = JSON.parse(parsed);
} catch {
return {} as T;
}
}
if (!parsed || typeof parsed !== "object") {
return {} as T;
}
return parsed as T;
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { catalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="a2ui-fixed-schema"
a2ui={{ catalog: catalog }}
>
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,13 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useA2UIFixedSchemaSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
}
@@ -0,0 +1,93 @@
"use client";
import type { ChangeEvent } from "react";
import {
EXPERTISE_OPTIONS,
RESPONSE_LENGTH_OPTIONS,
TONE_OPTIONS,
} from "./config-types";
import type {
AgentConfig,
Expertise,
ResponseLength,
Tone,
} from "./config-types";
interface ConfigCardProps {
config: AgentConfig;
onToneChange: (tone: Tone) => void;
onExpertiseChange: (expertise: Expertise) => void;
onResponseLengthChange: (length: ResponseLength) => void;
}
export function ConfigCard({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: ConfigCardProps) {
return (
<div
data-testid="agent-config-card"
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-[var(--text-muted)]">
Change these and send a message to see the agent adapt.
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Tone</span>
<select
data-testid="agent-config-tone-select"
value={config.tone}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onToneChange(e.target.value as Tone)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{TONE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Expertise</span>
<select
data-testid="agent-config-expertise-select"
value={config.expertise}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onExpertiseChange(e.target.value as Expertise)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{EXPERTISE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Response length</span>
<select
data-testid="agent-config-length-select"
value={config.responseLength}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onResponseLengthChange(e.target.value as ResponseLength)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
/**
* Publishes the current agent-config toggles to the agent runtime via
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
* context store is reachable. The middleware on the Python side reads
* this entry off the agent's runtime context on every turn and routes
* it into the model's prompt.
*/
import { useAgentContext } from "@copilotkit/react-core/v2";
import type { AgentConfig } from "./config-types";
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
useAgentContext({
description:
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
value: {
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
},
});
return null;
}
@@ -0,0 +1,26 @@
export type Tone = "professional" | "casual" | "enthusiastic";
export type Expertise = "beginner" | "intermediate" | "expert";
export type ResponseLength = "concise" | "detailed";
export interface AgentConfig {
tone: Tone;
expertise: Expertise;
responseLength: ResponseLength;
}
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
tone: "professional",
expertise: "intermediate",
responseLength: "concise",
};
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
export const EXPERTISE_OPTIONS: Expertise[] = [
"beginner",
"intermediate",
"expert",
];
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
"concise",
"detailed",
];

Some files were not shown because too many files have changed in this diff Show More