Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Shared\Shared.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI"/>
<PackageReference Include="Microsoft.Agents.AI.Abstractions"/>
</ItemGroup>

</Project>
80 changes: 80 additions & 0 deletions AgentCommunicationFaultTolerance.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using AgentCommunicationFaultTolerance.AgentFramework;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared;

// Fault tolerance for agent-to-agent messaging: ids, retry, dedup, dead-letters, reconciliation.
//
// Once agents talk over a network instead of a method call, every message has three outcomes, not
// two: arrived, lost, and "arrived but the acknowledgement was lost". IdempotentToolCalls solves
// the third one for a tool the agent calls; this solves it for a message the agent sends to
// another agent, where the retry and the effect are on opposite sides of the wire.

var client = Settings.ChatClient;

// The receiving agent's actual work: analysing a shipment note. Expensive enough that doing it
// twice matters, which is what makes dedup worth its bookkeeping.
var analyst = new ChatClientAgent(client, name: "Analyst",
instructions: "Given a shipment note, reply with one sentence: the risk to the delivery date.");

var effectLog = new List<string>();
string Effect(Message m)
{
// Synchronous by design: the dedup record and the effect must not be separable by an await,
// or two duplicates can both pass the check before either writes.
var reply = analyst.RunAsync(m.Body,
options: new ChatClientAgentRunOptions(new ChatOptions { Temperature = 0.2f }))
.GetAwaiter().GetResult().Text;
effectLog.Add(m.Id);
Console.WriteLine($" [effect ran] {m.Id} attempt {m.Attempt}: {reply.ReplaceLineEndings(" ").Trim()}");
return reply;
}

// Seeded so the run is reproducible: this seed loses some messages and duplicates others.
// Seed 11 exercises all four mechanisms: a retry, an absorbed duplicate, and one message
// that never gets through.
var transport = new FlakyTransport(seed: 11, lossRate: 0.45, duplicateRate: 0.35);
var inbox = new Inbox();
var channel = new ReliableChannel(transport, inbox, maxAttempts: 4);

Message[] outbound =
[
new("MSG-1", "Dispatcher", "Analyst", "Shipment SH-771: customs hold in Rotterdam, 2 days."),
new("MSG-2", "Dispatcher", "Analyst", "Shipment SH-772: carrier strike announced for Thursday."),
new("MSG-3", "Dispatcher", "Analyst", "Shipment SH-773: cold chain sensor offline since 04:00."),
new("MSG-4", "Dispatcher", "Analyst", "Shipment SH-774: on schedule, no exceptions.")
];

Console.WriteLine("=== Sending over a transport that loses 45% and duplicates 35% ===");
foreach (var message in outbound)
{
Console.WriteLine($"\n {message.Id} -> {message.To}");
var delivery = await channel.SendAsync(message, Effect);

Console.WriteLine(delivery.Delivered
? $" delivered on attempt {delivery.Attempts}{(delivery.Duplicate ? " (replayed from the inbox, effect NOT re-run)" : "")}"
: $" dead-lettered after {delivery.Attempts} attempts: {delivery.Error}");
}

// ── The third outcome: delivered, but the sender never learned it ────────────
// This is the case that forces the whole design. The sender cannot tell "lost" from
// "arrived, ack lost", so it resends - and the receiver must make that a no-op.
Console.WriteLine("\n=== Resending MSG-2, as a sender that lost the acknowledgement would ===");
var resend = await channel.SendAsync(outbound[1], Effect);
Console.WriteLine(resend.Duplicate
? " replayed from the inbox: the stored result came back and the analysis did NOT run again"
: " handled as new — this would be a dedup failure");

// ── Reconciliation ───────────────────────────────────────────────────────────
var missing = ReliableChannel.Reconcile(outbound, inbox);

Console.WriteLine($"\n=== Reconciliation ===");
Console.WriteLine($" sent: {outbound.Length} handled by receiver: {inbox.Handled.Count} " +
$"effects actually run: {effectLog.Count} dead-lettered: {channel.DeadLetters.Count} " +
$"duplicates absorbed: {channel.DuplicatesAbsorbed}");
Console.WriteLine(missing.Count == 0
? " no gap: every sent message is accounted for on the receiving side."
: $" gap: {string.Join(", ", missing)} never reached the receiver — requeue or escalate.");

Console.WriteLine($"\nEffects ran {effectLog.Count} time(s) for {inbox.Handled.Count} distinct message(s); " +
"duplicates cost a transport round trip, never a second analysis.");
89 changes: 89 additions & 0 deletions AgentCommunicationFaultTolerance.AgentFramework/ReliableChannel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
namespace AgentCommunicationFaultTolerance.AgentFramework;

public sealed record Message(string Id, string From, string To, string Body, int Attempt = 1);

public sealed record Delivery(string MessageId, bool Delivered, bool Duplicate, int Attempts, string? Error);

/// A transport that behaves like a real one: it loses things, and it delivers things twice.
///
/// Both failures come from the same place. A network that can drop the ACK forces the sender to
/// choose between "retry and risk a duplicate" and "don't retry and risk a loss" - there is no
/// third option, which is why at-least-once plus receiver-side dedup is the shape everyone
/// converges on. Exactly-once delivery is not a transport you can buy; it is idempotent handling
/// you have to write.
public sealed class FlakyTransport(int seed, double lossRate, double duplicateRate)
{
readonly Random random = new(seed);

public bool WillDrop() => random.NextDouble() < lossRate;
public bool WillDuplicate() => random.NextDouble() < duplicateRate;
}

/// Receiver-side dedup. The record of "I have handled this id" lives WITH the effect, so a
/// duplicate cannot slip between the check and the write.
public sealed class Inbox
{
readonly Dictionary<string, string> handled = new(StringComparer.Ordinal);

public IReadOnlyDictionary<string, string> Handled => handled;

/// Returns the effect's result and whether this was a replay rather than a first delivery.
public (string Result, bool Duplicate) Handle(Message message, Func<Message, string> effect)
{
if (handled.TryGetValue(message.Id, out var existing)) return (existing, true);

var result = effect(message);
handled[message.Id] = result;
return (result, false);
}
}

public sealed class ReliableChannel(FlakyTransport transport, Inbox inbox, int maxAttempts)
{
public List<Message> DeadLetters { get; } = [];

/// Duplicates the transport delivered that the inbox absorbed. Counted because dedup working
/// is otherwise completely invisible: a duplicate that is correctly ignored looks exactly
/// like a duplicate that never arrived, and "nothing happened" is a poor way to demonstrate
/// the guarantee the whole pattern exists to provide.
public int DuplicatesAbsorbed { get; private set; }

public async Task<Delivery> SendAsync(Message message, Func<Message, string> effect)
{
string? lastError = null;

for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
if (transport.WillDrop())
{
lastError = "transport dropped the message";
// Exponential backoff, deliberately tiny here so the sample stays watchable.
await Task.Delay(TimeSpan.FromMilliseconds(20 * Math.Pow(2, attempt - 1)));
continue;
}

var (_, duplicate) = inbox.Handle(message with { Attempt = attempt }, effect);

// The transport may also deliver the same bytes twice. Dedup makes that a no-op
// rather than a second side effect - which is the entire reason the id exists.
if (transport.WillDuplicate())
{
inbox.Handle(message with { Attempt = attempt }, effect);
DuplicatesAbsorbed++;
Console.WriteLine($" [transport delivered {message.Id} twice] absorbed by the inbox; " +
"the effect did not run again");
}

return new Delivery(message.Id, true, duplicate, attempt, null);
}

DeadLetters.Add(message);
return new Delivery(message.Id, false, false, maxAttempts, lastError);
}

/// The step people skip. Retries and dead-letters make each message's fate correct; only a
/// reconciliation pass makes the CONVERSATION correct - it is where you find out that agent B
/// is missing the one message agent A believes it sent.
public static IReadOnlyList<string> Reconcile(IEnumerable<Message> sent, Inbox inbox) =>
[.. sent.Select(m => m.Id).Where(id => !inbox.Handled.ContainsKey(id))];
}
84 changes: 84 additions & 0 deletions AgentRegistry.AgentFramework/AgentCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace AgentRegistry.AgentFramework;

/// What a peer publishes about itself. Everything except `Signature` is signed.
public sealed record AgentCard(
string Name,
string Endpoint,
string[] Capabilities,
DateTimeOffset ExpiresAt,
string Signature = "")
{
/// Canonical bytes to sign: field order fixed here, not by JSON property order, so a peer
/// that reserialises the card with different formatting still verifies.
public string Canonical() =>
JsonSerializer.Serialize(new object[]
{ Name, Endpoint, Capabilities.Order(StringComparer.Ordinal), ExpiresAt.ToUnixTimeSeconds() });
}

public sealed record DiscoveryResult(AgentCard? Card, string? RejectedBecause)
{
public bool Found => Card is not null;
}

/// Discovery with the verification step that makes it safe.
///
/// "Find an agent that can do X" is the easy half. The half that decides whether this is a
/// feature or a hole is what happens between finding a card and sending it work: an unverified
/// registry is a directory of anything anyone published, and dispatching to it hands your task -
/// and whatever context rides with it - to a name that claimed a capability.
///
/// So: signature first, expiry second, capability third, and only then an endpoint. A card that
/// fails any of them is not "degraded", it is not used.
public sealed class Registry(byte[] signingKey)
{
readonly List<AgentCard> cards = [];

public AgentCard Publish(AgentCard card) => Add(card with { Signature = Sign(card, signingKey) });

/// For the tampering demo: publishes a card exactly as given, signature and all.
public AgentCard PublishRaw(AgentCard card) => Add(card);

AgentCard Add(AgentCard card)
{
cards.Add(card);
return card;
}

public IReadOnlyList<DiscoveryResult> Discover(string capability, DateTimeOffset now)
{
var matches = cards.Where(c =>
c.Capabilities.Contains(capability, StringComparer.OrdinalIgnoreCase)).ToList();

return [.. matches.Select(card => Verify(card, now))];
}

public DiscoveryResult Verify(AgentCard card, DateTimeOffset now)
{
if (!CryptographicOperations.FixedTimeEquals(Decode(card.Signature),
Convert.FromBase64String(Sign(card, signingKey))))
return new DiscoveryResult(null, $"'{card.Name}': signature does not verify");

if (card.ExpiresAt <= now)
return new DiscoveryResult(null, $"'{card.Name}': card expired at {card.ExpiresAt:u}");

return new DiscoveryResult(card, null);
}

/// A malformed signature is a failed signature, not an exception: the card is attacker-shaped
/// input and every path through here must end in accept-or-reject.
static byte[] Decode(string signature)
{
var buffer = new byte[signature.Length];
return Convert.TryFromBase64String(signature, buffer, out var written) ? buffer[..written] : [];
}

// ponytail: HMAC with one shared registry key - enough to show sign/verify without a PKI.
// A real registry signs per-agent with asymmetric keys and publishes a JWKS, so a compromised
// consumer cannot mint cards; swap Sign/Verify for that when peers stop trusting each other.
static string Sign(AgentCard card, byte[] key) =>
Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(card.Canonical())));
}
16 changes: 16 additions & 0 deletions AgentRegistry.AgentFramework/AgentRegistry.AgentFramework.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Shared\Shared.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI"/>
<PackageReference Include="Microsoft.Agents.AI.Abstractions"/>
</ItemGroup>

</Project>
71 changes: 71 additions & 0 deletions AgentRegistry.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Security.Cryptography;
using AgentRegistry.AgentFramework;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared;

// Agent registry and discovery: how one agent finds another it was not configured with, and what
// it must check before sending it work.
//
// A2A answers "how do two agents talk". It does not answer "which agent, and why do you believe
// its capability claim". That is this pattern: publish signed cards, discover by capability,
// verify signature and expiry, and only then dispatch. Everything interesting is in the gap
// between "found a card" and "sent it the task".

var registryKey = RandomNumberGenerator.GetBytes(32);
var registry = new Registry(registryKey);
var now = DateTimeOffset.UtcNow;

// ── Three peers publish ──────────────────────────────────────────────────────
registry.Publish(new AgentCard("translator-nordics", "https://agents.internal/translate",
["translate", "detect-language"], now.AddDays(30)));

registry.Publish(new AgentCard("invoice-extractor", "https://agents.internal/invoices",
["extract-invoice", "translate"], now.AddDays(30)));

// An expired card: still in the directory, still claims the capability.
registry.Publish(new AgentCard("legacy-translator", "https://agents.internal/old-translate",
["translate"], now.AddDays(-1)));

// A forged card: correct shape, plausible name, signature from a key the registry does not know.
var forged = new AgentCard("translator-premium", "https://evil.example/collect",
["translate"], now.AddDays(30), Signature: Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
registry.PublishRaw(forged);

// ── Discover ─────────────────────────────────────────────────────────────────
Console.WriteLine("=== Discovering 'translate' ===");
var found = registry.Discover("translate", now);
foreach (var result in found)
Console.WriteLine(result.Found
? $" ok {result.Card!.Name} -> {result.Card.Endpoint}"
: $" rejected {result.RejectedBecause}");

var usable = found.Where(r => r.Found).Select(r => r.Card!).ToList();
if (usable.Count == 0)
{
Console.WriteLine("\nNo verifiable peer offers 'translate'; the task does not go out.");
return;
}

// Deterministic choice among verified peers - most specific first, then name, so two runs of
// the same registry dispatch to the same peer.
var chosen = usable.OrderBy(c => c.Capabilities.Length).ThenBy(c => c.Name, StringComparer.Ordinal).First();
Console.WriteLine($"\nDispatching to {chosen.Name} ({chosen.Endpoint}), " +
$"capabilities [{string.Join(", ", chosen.Capabilities)}]");

// ── Dispatch ─────────────────────────────────────────────────────────────────
// Stands in for the A2A call the endpoint would receive - the point of this sample is what had
// to be true before this line runs, not the transport.
var peer = new ChatClientAgent(Settings.ChatClient, name: chosen.Name,
instructions: "You translate text into English and state the source language. Nothing else.");

var task = "Fakturaen forfaller den 30. november og maa betales i norske kroner.";
Console.WriteLine($"\nTask: {task}");
Console.WriteLine(await peer.RunAsync(task,
options: new ChatClientAgentRunOptions(new ChatOptions { Temperature = 0f })));

// ── Tampering after publication ──────────────────────────────────────────────
// The endpoint is inside the signed canonical form, so redirecting it breaks the signature.
var redirected = chosen with { Endpoint = "https://evil.example/collect" };
Console.WriteLine($"\n=== Re-verifying a card whose endpoint was swapped ===\n " +
(registry.Verify(redirected, now).RejectedBecause ?? "accepted (this would be a bug)"));
Loading