MICROSOFT AGENT FRAMEWORK · 0.16.0-rc.1
Streaming and interfaces
Separate display, transport and persistent state; reconstruct exact text and wait for save confirmation.
Objectives
- Frames, whitespace and exact reconstruction
- Terminal state and cancellation
- Sessions and server isolation
- Console, Web and WPF MVVM Toolkit
Share one engine across several interfaces
A console can reveal a response, but an application must also represent waiting, partial text, cancellation and failure. Demo410 places the agent and instructions in a shared ASP.NET Core backend. Console, Web and WPF send a request and display the result without owning the provider key. Changing frontend reuses the backend contract; each turn creates an agent and restores its session rather than keeping one global agent object.
- Console / WPF → Client library → HTTP :5088
- Browser → Web shell :5089 → HTTP :5088
- Backend → MAF session + configured model → snapshot
- /incident command → precompiled stdio MCP server → generation context
The backend offers POST /discussion for a complete response and POST /discussion/flux for NDJSON streaming. Both use the same RunStreamingAsync generation and storage; the former accumulates text before responding. The SiteMAF view is a static document explaining these local programs. It hosts neither this backend, the agent nor an online chat.
Frames, whitespace and exact reconstruction
The request carries sessionId in GUID N format, 32 hexadecimal characters, and a nonblank message of 1 to 4096 characters. The HTTP limit is 32 KiB. The client sends no display history. Each UTF-8 stream line is a JSON object with type text, completed or error. A newline inside text is JSON-escaped; the newline between frames delimits the protocol.
await engine.RunAsync(request, async fragment =>
{
if (streaming)
{
if (!context.Response.HasStarted)
{
context.Response.ContentType = "application/x-ndjson; charset=utf-8";
context.Response.Headers.CacheControl = "no-store";
}
await WriteEventAsync(context, new("text", fragment), token);
}
else text.Append(fragment);
}, token);
if (streaming)
{
if (!context.Response.HasStarted)
{
context.Response.ContentType = "application/x-ndjson; charset=utf-8";
context.Response.Headers.CacheControl = "no-store";
}
await WriteEventAsync(context, new("completed"), token);
}
else await context.Response.WriteAsJsonAsync(new { message = text.ToString() }, token);
Source code · Demo410 · Folder
- demos/Demo410_StreamingInterfaces/Backend/Demo410.Backend.csproj
- demos/Demo410_StreamingInterfaces/Client/Demo410.Client.csproj
- demos/Demo410_StreamingInterfaces/Console/Demo410.Console.csproj
- demos/Demo410_StreamingInterfaces/Web/Demo410.Web.csproj
- demos/Demo410_StreamingInterfaces/Wpf/Demo410.Wpf.csproj
using var response = await http.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(token);
using var reader = new StreamReader(stream);
while (await reader.ReadLineAsync(token) is { } line)
{
var frame = JsonSerializer.Deserialize<StreamEvent>(line, JsonSerializerOptions.Web)
?? throw new InvalidDataException("Invalid stream event.");
switch (frame.Type)
{
case "text": onText(frame.Text ?? throw new InvalidDataException("Missing text.")); break;
case "completed": return;
case "error": throw new InvalidOperationException(frame.Text ?? "Generation failed.");
default: throw new InvalidDataException("Unknown stream event.");
}
}
throw new EndOfStreamException("The response ended before completion; completion is unconfirmed.");
Source code · Demo410 · Folder
- demos/Demo410_StreamingInterfaces/Backend/Demo410.Backend.csproj
- demos/Demo410_StreamingInterfaces/Client/Demo410.Client.csproj
- demos/Demo410_StreamingInterfaces/Console/Demo410.Console.csproj
- demos/Demo410_StreamingInterfaces/Web/Demo410.Web.csproj
- demos/Demo410_StreamingInterfaces/Wpf/Demo410.Wpf.csproj
The engine ignores only null or empty text. A fragment containing a space, tab or newline must be appended unchanged. Do not apply Trim or IsNullOrWhiteSpace to fragments. A model token, MAF update, network packet and HTTP read have different boundaries. The .NET reader assembles lines; the browser retains incomplete lines and uses streaming TextDecoder followed by an EOF flush to support UTF-8 characters split across reads.
The Web interface appends fragments through textContent to the same response node; WPF updates Text on the same DisplayMessage. This preserves data and displayed element identity. This stream publishes neither hidden reasoning, token usage nor tool-call deltas. The /incident command explicitly triggers MCP before generation: it does not demonstrate autonomous tool selection.
Confirm completion and propagate cancellation
Initial fragments do not establish success. The server emits completed after generation ends and the session is saved. A failure before headers returns 502; invalid input returns 400; concurrent generation returns 409. After streaming starts, failure emits error and no completed. A connection ending without completed remains a truncated response. The client retains partial text and displays unconfirmed completion.
await foreach (var update in agent.RunStreamingAsync(prompt, session, cancellationToken: token))
{
if (!string.IsNullOrEmpty(update.Text)) await onText(update.Text);
}
token.ThrowIfCancellationRequested();
await store.SaveAsync(agent, session, token);
Source code · Demo410 · Folder
- demos/Demo410_StreamingInterfaces/Backend/Demo410.Backend.csproj
- demos/Demo410_StreamingInterfaces/Client/Demo410.Client.csproj
- demos/Demo410_StreamingInterfaces/Console/Demo410.Console.csproj
- demos/Demo410_StreamingInterfaces/Web/Demo410.Web.csproj
- demos/Demo410_StreamingInterfaces/Wpf/Demo410.Wpf.csproj
- Generation → visible fragments
- Generation ends → token check → SaveAsync
- Snapshot replacement → completed frame → client confirmation
- Disconnect after save and before acknowledgement: state may be committed, completion unconfirmed
Web AbortController, WPF SendCancelCommand and console Ctrl+C cancel HTTP reads. RequestAborted joins the server’s 120-second deadline token, then MAF, MCP and saving. Cancellation received before commit leaves the last successful snapshot intact; after commit it cannot undo it. The client cannot always know where disconnection occurred: there is no automatic retry, last-fragment resume, deduplication identifier or exactly-once guarantee.
Persistent session and screen history
The backend normalizes the identifier before choosing the SessionFile and checks instruction version video5-1. Each turn reloads a fresh native session with bounded history. Only a successful turn atomically replaces the adjacent file; a corrupt session fails explicitly rather than being reset. Default state lives under MAF_STATE_ROOT/video5 or LocalApplicationData; Conversation:StateRoot allows a dedicated directory.
Reusing the same ID after restart resumes saved context without repopulating visible bubbles. Web retains its ID in sessionStorage; WPF allows editing it. New conversation creates an ID and clears the screen but leaves old files. Only one generation is admitted across the process: the gate is not per session and does not coordinate two backend processes. Do not share a state directory between concurrent backends.
Console, Web and WPF with MVVM Toolkit
DiscussionClient centralizes POST and NDJSON reading for console and WPF. DiscussionViewModel derives from ObservableObject: ObservableProperty generates SessionId, Input and Status; Messages is an ObservableCollection of observable DisplayMessage objects. RelayCommand on SendAsync generates asynchronous SendCommand and SendCancelCommand; a second concurrent execution is disallowed by default. The command creates one answer, then text => answer.Text += text grows it.
[RelayCommand(IncludeCancelCommand = true)]
private async Task SendAsync(CancellationToken token)
{
if (string.IsNullOrWhiteSpace(Input)) return;
var prompt = Input;
Input = "";
Messages.Add(new("User") { Text = prompt });
var answer = new DisplayMessage("Assistant");
Messages.Add(answer);
Status = "Generating";
try
{
await client.StreamAsync(new(SessionId, prompt), text => answer.Text += text, token);
Status = "Completed and saved";
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { Status = "Canceled; completion unconfirmed. Partial display only."; }
catch (Exception error) { Status = $"Failed; completion unconfirmed. {error.Message}"; }
Source code · Demo410 · Folder
- demos/Demo410_StreamingInterfaces/Backend/Demo410.Backend.csproj
- demos/Demo410_StreamingInterfaces/Client/Demo410.Client.csproj
- demos/Demo410_StreamingInterfaces/Console/Demo410.Console.csproj
- demos/Demo410_StreamingInterfaces/Web/Demo410.Web.csproj
- demos/Demo410_StreamingInterfaces/Wpf/Demo410.Wpf.csproj
MainWindow constructs the client and assigns DataContext; XAML binds ItemsSource to Messages, input to Input and buttons to commands. Code-behind contains neither inference nor MCP protocol. The ViewModel owns display state and commands; the backend owns the durable conversation. NewConversation refuses to change the ID while SendCommand.IsRunning. The WPF ID remains editable: changing it during a response can make the screen ambiguous; the server uses the ID captured in the initial request.
The Razor Web shell serves the page and JavaScript, which calls the backend directly using Backend:Url. Competing actions are disabled and Cancel calls AbortController. The console writes each fragment and prints Completed and saved only after the reader returns normally. Testing a ViewModel does not replace observing an actual interactive WPF window or Web page.
Local exposure and available evidence
The backend listens on loopback and rejects non-loopback peers. CORS allows the exact http://localhost:5089 origin by default for POST and Content-Type; localhost and 127.0.0.1 are not interchangeable origins. CORS does not authenticate native clients. IDs are routing keys, not access secrets. This local setup supplies neither authentication, protected multiuser access nor a remote deployment contract.
Tests use real HTTP transports, sessions and MCP subprocesses with scripted model responses. They check exact text, restart, truncation, cancellation, concurrency gating and Toolkit commands. They establish neither semantic model quality, graphical Web/WPF acceptance nor general enterprise protection. Acceptance results are distinguished on the demo page.
Exercise the interfaces
Prerequisites
Continue reading
Previous
Next
