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
1 change: 1 addition & 0 deletions AgenticPatterns.Tests/NewOrchestrationPatternTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public async Task TwoHandlersFeedingEachOtherAreStoppedByTheGenerationCap()
public void AnEventNobodySubscribesToIsRecordedNotDropped()
{
var bus = new EventBus(maxEvents: 10, maxGeneration: 5);
bus.RegisterTerminal("nobody-listens");

// Not queued, but not lost either. Which list it lands in is asserted by
// EventBusTaxonomyTests - a terminal event is a workflow output, not a delivery failure.
Expand Down
18 changes: 16 additions & 2 deletions AgenticPatterns.Tests/ReviewFollowupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,30 @@ public class EventBusTaxonomyTests
static AgentEvent Event(string topic, int generation = 0) => new(topic, "payload", "test", generation);

[Fact]
public void AnEventNobodySubscribesToIsTerminalNotADeadLetter()
public void ADeclaredTerminalTopicIsAnOutcomeNotADeadLetter()
{
var bus = new EventBus(maxEvents: 10, maxGeneration: 5);
bus.RegisterTerminal("workflow-finished");

bus.Publish(Event("nobody-listens"));
bus.Publish(Event("workflow-finished"));

Assert.Single(bus.TerminalEvents);
Assert.Empty(bus.DeadLetters);
}

[Fact]
public void AnUndeclaredTopicIsADeliveryFailure()
{
var bus = new EventBus(maxEvents: 10, maxGeneration: 5);

// The typo case: neither subscribed nor registered as terminal. Inferring "terminal" from
// an empty handler list would make this a successful outcome.
bus.Publish(Event("DecisionMdae"));

Assert.Empty(bus.TerminalEvents);
Assert.Equal(Refusal.NoSubscriber, bus.DeadLetters.Single().Reason);
}

[Fact]
public async Task AGenerationCapProducesADeadLetterWithThatReason()
{
Expand Down
38 changes: 26 additions & 12 deletions ChainOfVerification.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
// Roman founding dates that is not a remote possibility. What you get is a blind cross-check:
// strong evidence when it disagrees, weak evidence when it agrees. Real independence needs a
// different source - retrieval, a tool, a second model - which is what **AgenticRAG** brings.
//
// Which is why a disagreement here is a FLAG, not a correction. No new fact entered the system
// between the draft and the check; preferring the check would just be preferring the model's
// later guess to its earlier one. And the checker's own "CONFIDENT:" is a self-report, not
// evidence - a label the same weights wrote about themselves cannot promote the second guess
// into an authority. So disagreement resolves to contested, and settling it is somebody else's
// job: retrieval, a calculator, an authoritative record.

var client = Settings.ChatClient;
var lowTemp = new ChatClientAgentRunOptions(new ChatOptions { Temperature = 0.2f });
Expand Down Expand Up @@ -70,6 +77,8 @@ Return at most 8 claims.
// ── 3. Answer each check blind ───────────────────────────────────────────────
// A fresh stateless agent, one question per run, no session, no draft in context.
// This is the structural difference from a self-critique loop.
// The CONFIDENT:/UNCERTAIN: prefix is worth having, and worth being clear about: it tells a
// reader how firmly the checker holds its answer. It does not decide which value wins.
var verifier = new ChatClientAgent(client, name: "Verifier",
instructions: "Answer the single factual question as precisely as you can. Begin your reply " +
"with CONFIDENT: or UNCERTAIN: — uncertainty is a useful answer and a guess " +
Expand All @@ -86,26 +95,29 @@ Return at most 8 claims.
Console.WriteLine($" [{claim.Id}] {question}\n → {answer.ReplaceLineEndings(" ")}\n");

// ── 4. Revise ────────────────────────────────────────────────────────────────
// The reviser sees the draft and the independent answers side by side, and is told which one
// wins when they disagree. Without that instruction the model tends to defend its own draft.
// The reviser sees the draft and the blind answers side by side, and is told that neither wins.
// Without an explicit rule the model either defends its own draft or capitulates to the check,
// and both are the same mistake: treating one of two same-weights guesses as the authority.
var reviser = new ChatClientAgent(client, name: "Reviser",
instructions: """
You are given a draft answer and a set of blind cross-checks: the same model
answering each factual question with the draft out of sight.

A cross-check is not an authority. Resolve each disagreement into one of three
outcomes, and never silently keep the draft:
A cross-check is not an authority, and neither is the draft. Resolve every
claim into one of two outcomes, and never silently keep a disagreement:

- check CONFIDENT and disagrees -> correct the draft to the check.
- check UNCERTAIN and disagrees -> mark the claim contested: state both
values and that they could not be settled. Do not pick one.
- check agrees -> leave the claim as it is. Agreement between
a model and itself is weak evidence, so do not upgrade the wording.
- check agrees -> leave the claim as it is. Agreement between a model and
itself is weak evidence, so do not upgrade the wording.
- check disagrees -> mark the claim contested: state both values and that
nothing here could settle them. Do NOT pick one, and do not let the
check's own CONFIDENT:/UNCERTAIN: label pick for you — that label is the
checker describing itself, not evidence about the world. Report it as
what it is: "the blind check said X (self-reported confident)".

Do not add new claims.

Output the corrected answer, then "Changes:" listing corrections and contested
claims separately.
Output the answer with contested claims marked inline, then "Changes:"
listing every contested claim and what would settle it.
""");

var evidence = string.Join("\n", answers.Select(a => $"Q: {a.Question}\nA: {a.Answer}"));
Expand Down Expand Up @@ -141,7 +153,9 @@ claims separately.
: "\nEvery claim in the draft was extracted and cross-checked.");

Console.WriteLine("Cross-checked is not verified: the checker shares the drafter's weights, so "
+ "agreement rules out anchoring on the draft, not a shared misconception.");
+ "agreement rules out anchoring on the draft, not a shared misconception — and "
+ "a disagreement is a flag, not a correction. Nothing here can settle one; that "
+ "needs a source outside the model.");

// Structured-output shape for the planning call.
internal sealed record PlannedClaim(int Id, string Text, string Value, string Question);
Expand Down
9 changes: 5 additions & 4 deletions DualLlm.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ You extract one value from a document. You have no tools and no ability to act.

// ── What taint does NOT buy ──────────────────────────────────────────────────
// The run above depends on the quarantined model reporting the real total. Suppose it had
// complied with the injection instead and returned 48000.00: that is a well-formed decimal,
// inside the range bound, and it would file. Control flow is still intact - no new step, no new
// tool - and the expense is still wrong. Only the value policy stops it, and it is worth seeing
// that stop happen rather than trusting that it would.
// complied with the injection instead and returned 48000.00: that is a well-formed decimal, so
// it passes the type gate untouched. Control flow is still intact - no new step, no new tool -
// and the expense is still wrong. What stops it is the second, separate gate: the unattended
// value policy holds it for a person. Worth watching both gates run rather than trusting that
// they would.
var injected = new Value("invoice_total", "decimal", "48000.00", Tainted: true);
Console.WriteLine($"\n=== If the quarantined model had returned the injected figure ===");
Console.WriteLine($" coerces to a valid decimal: {DataFlowPlan.TryCoerce(injected, "decimal", out _)}");
Expand Down
18 changes: 16 additions & 2 deletions EventDrivenAgents.AgentFramework/EventBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ public sealed record DeadLetter(AgentEvent Event, Refusal Reason);
///
/// Refused events are kept with a reason rather than dropped: a silent drop looks exactly like a
/// handler that never fired.
///
/// Terminal topics are declared, not inferred. "Nobody subscribes" is ambiguous - it is either
/// the workflow finishing or a topic name nobody will ever match - and in a system whose wiring
/// IS the subscription table, a typo is the likeliest wiring bug there is. Inferring terminal
/// from an empty handler list makes `DecisionMdae` a successful outcome.
public sealed class EventBus(int maxEvents, int maxGeneration)
{
readonly Channel<AgentEvent> channel = Channel.CreateUnbounded<AgentEvent>();
readonly Dictionary<string, List<Func<AgentEvent, Task<IReadOnlyList<AgentEvent>>>>> handlers =
new(StringComparer.OrdinalIgnoreCase);
readonly HashSet<string> terminalTopics = new(StringComparer.OrdinalIgnoreCase);

/// Events refused by the budget or the generation cap. A dead letter is a FAILURE - something
/// that could not be processed.
Expand All @@ -43,14 +49,18 @@ public sealed class EventBus(int maxEvents, int maxGeneration)

public int Published { get; private set; }

/// Declares a topic as an outcome of the run: legal to publish, nothing reacts to it.
public void RegisterTerminal(string topic) => terminalTopics.Add(topic);

public void Subscribe(string topic, Func<AgentEvent, Task<IReadOnlyList<AgentEvent>>> handler)
{
if (!handlers.TryGetValue(topic, out var list)) handlers[topic] = list = [];
list.Add(handler);
}

/// Returns false when the event was not queued - because the run is finished with it
/// (terminal), or because a limit refused it (dead letter). The two are recorded separately.
/// (terminal), or because a limit or an unknown topic refused it (dead letter). The two are
/// recorded separately.
public bool Publish(AgentEvent @event)
{
if (Published >= maxEvents)
Expand All @@ -61,7 +71,11 @@ public bool Publish(AgentEvent @event)

if (!handlers.ContainsKey(@event.Topic))
{
// Nothing left to react to. That is the workflow ending, not a delivery failing.
// A declared terminal topic is the workflow ending, not a delivery failing. An
// undeclared one is a message addressed to nobody - which is what a misspelled topic
// looks like, and it should be loud.
if (!terminalTopics.Contains(@event.Topic)) return Refuse(@event, Refusal.NoSubscriber);

TerminalEvents.Add(@event);
return false;
}
Expand Down
20 changes: 16 additions & 4 deletions EventDrivenAgents.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
"Approver", 0)
]);

// Nothing subscribes to DecisionMade. That makes it a TERMINAL event - the workflow finished -
// which the bus records separately from dead letters. A workflow output filed as a delivery
// failure makes the dead-letter queue useless as an alarm.
// Nothing subscribes to DecisionMade, and that is declared rather than inferred: it is a TERMINAL
// topic, an outcome of the run, which the bus records separately from dead letters. A workflow
// output filed as a delivery failure makes the dead-letter queue useless as an alarm - and,
// inferred the other way round, a topic nobody can match becomes a silent success.
bus.RegisterTerminal("DecisionMade");

bus.Publish(new AgentEvent("PurchaseRequested",
"Purchase request: 3-year contract with a Norwegian logistics SaaS vendor, EUR 84,000/year, " +
Expand All @@ -60,10 +62,20 @@ await bus.RunToCompletionAsync(e =>
Console.WriteLine($"\n=== Done: {bus.Published} events dispatched ===");
foreach (var terminal in bus.TerminalEvents)
Console.WriteLine($" terminal: {terminal.Topic} (gen {terminal.Generation}) from {terminal.Source} " +
"— nothing subscribes, the workflow ends here");
$"— registered as an outcome, the workflow ends here\n {terminal.Payload}");
foreach (var dead in bus.DeadLetters)
Console.WriteLine($" dead-letter: {dead.Event.Topic} (gen {dead.Event.Generation}) " +
$"from {dead.Event.Source} — {dead.Reason}");

if (bus.DeadLetters.Count == 0)
Console.WriteLine(" no dead letters: nothing hit the event budget or the generation cap.");

// ── What "the wiring is the subscription table" costs ────────────────────────
// There is no compiler for a topic name. A handler that publishes "DecisionMdae" is not a broken
// handler you can find by reading it - it is a message addressed to nobody, and the only reason
// it is visible here is that the bus refuses topics it was never told about.
Console.WriteLine("\n=== A misspelled topic, published by nobody's mistake in particular ===");
bus.Publish(new AgentEvent("DecisionMdae", "APPROVE", "Approver", 3));
var typo = bus.DeadLetters[^1];
Console.WriteLine($" dead-letter: {typo.Event.Topic} — {typo.Reason}. Neither subscribed nor " +
"registered as terminal, so it is a delivery failure and says so.");
43 changes: 32 additions & 11 deletions GraphRAG.AgentFramework/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,40 @@ cluster is about and what recurs in it.
// believing them. Without this the provenance chain breaks exactly here: documents carry ids,
// relations carry ids, and then a free-text summary carries whatever the model happened to
// retain - after which the final answerer is asked to "cite the incident ids" and can only
// repeat, or invent, what reached it. An id the summariser names that is not in the community
// is a fabrication, and it is cheap to catch because the truth is a set the host already has.
// repeat, or invent, what reached it.
//
// The citation is the INTERSECTION, and both halves of that matter. An id the summariser
// names that is not in the community is a fabrication. An id in the community that the
// summariser did not use is not support for this summary - attaching the whole community
// would trade fabricated provenance for inflated provenance, which reads exactly as
// convincing and is just as untrue. The host can only vouch for what the model claimed AND
// the graph contains.
var actual = community.Select(r => r.SourceDoc).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray();
var claimed = summarised.SourceDocumentIds ?? [];
var validated = claimed.Intersect(actual, StringComparer.OrdinalIgnoreCase)
.Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray();
var fabricated = claimed.Except(actual, StringComparer.OrdinalIgnoreCase).ToArray();

// Cite what the community actually contains - the host's set, not the model's recollection.
summaries.Add($"Community {index + 1} [sources: {string.Join(", ", actual)}]: {summarised.Summary}");
var unused = actual.Except(validated, StringComparer.OrdinalIgnoreCase).ToArray();

Console.WriteLine($"\n Community {index + 1} ({community.Count} relations, " +
$"{community.SelectMany(r => new[] { r.From, r.To }).Distinct(StringComparer.OrdinalIgnoreCase).Count()} entities)");
Console.WriteLine($" {summarised.Summary}");
Console.WriteLine($" sources (from the graph): {string.Join(", ", actual)}");
Console.WriteLine($" validated sources: {(validated.Length == 0 ? "(none)" : string.Join(", ", validated))}");
if (fabricated.Length > 0)
Console.WriteLine($" [provenance] summariser also claimed {string.Join(", ", fabricated)} — " +
"not in this community, dropped");
Console.WriteLine($" [provenance] claimed {string.Join(", ", fabricated)} — not in this " +
"community, dropped as fabricated");
if (unused.Length > 0)
Console.WriteLine($" [provenance] {string.Join(", ", unused)} are in this community but " +
"were not claimed as used — not cited as support");

if (validated.Length == 0)
{
Console.WriteLine(" [provenance] no claimed id survives — the summary is not carried " +
"to the global answer. A claim nothing can be traced to is not evidence.");
continue;
}

summaries.Add($"Community {index + 1} [sources: {string.Join(", ", validated)}]: {summarised.Summary}");
}

var answerer = new ChatClientAgent(client, name: "Answerer",
Expand All @@ -114,9 +132,12 @@ cluster is about and what recurs in it.
// ── 3a. Global question: answered from community summaries ───────────────────
Console.WriteLine("\n=== Global question ===");
Console.WriteLine("Q: What is the recurring systemic problem across these incidents?\n");
Console.WriteLine(await answerer.RunAsync(
$"Community summaries:\n{string.Join("\n", summaries)}\n\n" +
"Q: What is the recurring systemic problem across these incidents?", options: precise));
Console.WriteLine(summaries.Count == 0
? " (no summary kept its provenance, so there is no evidence to answer from. Saying that is\n" +
" the answer; synthesising one from unattributable text is the failure this guards.)"
: (await answerer.RunAsync(
$"Community summaries:\n{string.Join("\n", summaries)}\n\n" +
"Q: What is the recurring systemic problem across these incidents?", options: precise)).Text);

// ── 3b. Local question: answered from a neighbourhood ────────────────────────
var neighbourhood = graph.Neighbourhood("Team Atlas", hops: 2);
Expand Down
Loading