MICROSOFT AGENT FRAMEWORK · 0.16.0-rc.1
Streaming et interfaces
Séparer affichage, transport et état persistant ; reconstruire le texte exact et attendre la confirmation de sauvegarde.
Objectifs
- Trames, espaces et reconstruction exacte
- État terminal et annulation
- Sessions et isolation serveur
- Console, Web et WPF MVVM Toolkit
Partager un moteur entre plusieurs interfaces
Une console suffit pour observer une réponse, mais une application doit aussi représenter attente, texte partiel, annulation et échec. Demo410 place l’agent et ses instructions dans un backend ASP.NET Core commun. Console, Web et WPF envoient une demande et affichent le résultat sans posséder la clé du fournisseur. Changer de frontend réutilise le contrat du backend ; chaque tour crée un agent et restaure sa session, sans conserver un unique objet agent global.
- Console / WPF → bibliothèque Client → HTTP :5088
- Navigateur → shell Web :5089 → HTTP :5088
- Backend → session MAF + modèle configuré → snapshot
- Commande /incident → serveur MCP stdio précompilé → contexte de génération
Le backend propose POST /discussion pour une réponse complète et POST /discussion/flux pour un flux NDJSON. Les deux utilisent la même génération RunStreamingAsync et le même stockage ; le premier accumule le texte avant de répondre. La vue du SiteMAF est un document statique qui explique ces programmes locaux. Elle n’héberge ni ce backend, ni l’agent, ni un chat utilisable en ligne.
Trames, espaces et reconstitution exacte
La requête transporte sessionId au format GUID N, soit 32 caractères hexadécimaux, et message de 1 à 4096 caractères non blancs. La limite HTTP est de 32 Kio. Le client n’envoie aucun historique d’affichage. Chaque ligne UTF-8 du flux est un objet JSON avec type text, completed ou error. Une nouvelle ligne dans text est échappée par JSON ; le retour de ligne entre trames délimite le protocole.
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);
Code source · Demo410 · Dossier
- 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.");
Code source · Demo410 · Dossier
- 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
Le moteur ignore uniquement le texte nul ou vide. Un fragment contenant un espace, une tabulation ou une nouvelle ligne doit être ajouté tel quel. Ne pas appliquer Trim ou IsNullOrWhiteSpace aux fragments. Un token modèle, un update MAF, un paquet réseau et une lecture HTTP ont des frontières différentes. Le lecteur .NET assemble des lignes ; le navigateur conserve les lignes incomplètes et utilise TextDecoder en mode streaming puis le vide à EOF, pour supporter les caractères UTF-8 coupés entre lectures.
L’interface Web ajoute les fragments avec textContent au même nœud de réponse ; WPF modifie Text du même DisplayMessage. On préserve ainsi les données et l’identité de l’élément affiché. Ce flux ne publie ni raisonnement caché, ni usage tokens, ni deltas d’appels d’outils. La commande /incident déclenche explicitement MCP avant la génération : elle ne démontre pas une sélection autonome d’outil.
Confirmer la fin et propager l’annulation
Les premiers fragments ne prouvent pas la réussite. Le serveur émet completed après la fin de génération et la sauvegarde de session. Une panne avant les en-têtes retourne 502 ; une demande invalide 400 ; une génération concurrente 409. Après le début du flux, une panne émet error et aucun completed. Une fin de connexion sans completed reste une réponse tronquée. Le client conserve le texte partiel et affiche une fin non confirmée.
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);
Code source · Demo410 · Dossier
- 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
- Génération → fragments visibles
- Fin de génération → contrôle du token → SaveAsync
- Remplacement du snapshot → trame completed → confirmation client
- Déconnexion après sauvegarde et avant confirmation : état potentiellement commis, fin non confirmée
AbortController côté Web, SendCancelCommand côté WPF et Ctrl+C côté console annulent les lectures HTTP. RequestAborted rejoint le token de délai de 120 secondes du serveur, puis MAF, MCP et la sauvegarde. Une annulation reçue avant commit laisse le dernier snapshot réussi intact ; après commit elle ne peut pas l’annuler. Le client ne sait pas toujours où la coupure s’est produite : pas de retry automatique, de reprise au dernier fragment, d’identifiant de déduplication ou de garantie exactly-once.
Session persistante et historique d’écran
Le backend normalise l’identifiant avant de choisir le fichier SessionFile et vérifie la version d’instructions video5-1. Chaque tour recharge une session native fraîche avec un historique borné. Seul un tour réussi remplace atomiquement le fichier voisin ; une session corrompue échoue explicitement au lieu d’être réinitialisée. L’état par défaut vit sous MAF_STATE_ROOT/video5 ou LocalApplicationData ; Conversation:StateRoot permet un dossier dédié.
Réutiliser le même ID après redémarrage reprend le contexte sauvegardé, sans repeupler les bulles visibles. Le Web conserve son ID dans sessionStorage ; WPF permet de le saisir. New conversation crée un ID et efface l’écran mais laisse les anciens fichiers. Une seule génération est admise pour tout le processus : le verrou n’est pas par session et ne coordonne pas deux processus backend. Ne pas partager le même dossier d’état entre backends concurrents.
Console, Web et WPF avec MVVM Toolkit
DiscussionClient concentre le POST et la lecture NDJSON pour console et WPF. DiscussionViewModel dérive d’ObservableObject : ObservableProperty génère SessionId, Input et Status ; Messages est une ObservableCollection de DisplayMessage observables. RelayCommand sur SendAsync génère SendCommand asynchrone et SendCancelCommand ; une seconde exécution concurrente n’est pas admise par défaut. La commande crée une seule réponse, puis text => answer.Text += text la fait grandir.
[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}"; }
Code source · Demo410 · Dossier
- 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 construit le client et affecte DataContext ; le XAML lie ItemsSource à Messages, la saisie à Input et les boutons aux commandes. Le code-behind ne porte ni inférence ni protocole MCP. Le ViewModel possède l’état d’affichage et la commande, le backend possède la conversation durable. NewConversation refuse de changer d’ID pendant SendCommand.IsRunning. L’ID WPF reste éditable : le tourner en cours de réponse peut rendre l’écran ambigu ; le serveur traite l’ID capturé dans la requête initiale.
Le shell Web Razor sert la page et le JavaScript, lequel appelle directement le backend configuré par Backend:Url. Les actions concurrentes sont désactivées et Cancel appelle AbortController. La console écrit chaque fragment et n’imprime Completed and saved qu’après retour normal du lecteur. Une vérification de ViewModel sous tests ne remplace pas l’observation d’une fenêtre WPF ou d’une page Web réellement interactive.
Exposition locale et preuves disponibles
Le backend écoute en loopback et refuse les pairs non loopback. CORS accepte par défaut l’origine exacte http://localhost:5089 pour POST et Content-Type ; localhost et 127.0.0.1 ne sont pas des origines interchangeables. CORS n’authentifie pas les clients natifs. Les ID sont des clés de routage, pas des secrets d’accès. Ce montage local ne fournit ni authentification, ni multiutilisateur protégé, ni contrat de déploiement distant.
Les tests utilisent de vrais transports HTTP, sessions et sous-processus MCP avec des réponses modèle scriptées. Ils vérifient texte exact, restart, troncature, annulation, garde concurrent et commandes Toolkit. Ils n’établissent ni qualité sémantique du modèle, ni recette graphique Web/WPF, ni protection générale du SI. Les résultats de recette sont distingués sur la fiche de démo.
Manipuler les interfaces
Prérequis
Pour poursuivre
Précédent
Suivant
