chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 `<ui>...</ui>`
|
||||
/// 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.<name>(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.<name>(...)</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:
|
||||
///
|
||||
/// <reasoning>
|
||||
/// step-by-step thinking...
|
||||
/// </reasoning>
|
||||
/// 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><reasoning>...</reasoning></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><reasoning>...</reasoning></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<ChatMessage> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user