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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/bin
/obj
**/bin/
**/obj/
config.json
14 changes: 7 additions & 7 deletions AuthorAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger<AuthorAgent
_logger.LogInformation("AuthorAgent initialized.");
}

public async Task<string> InvokeAsync(ResearchState state)
public async Task<string> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
using Activity? activity = s_activitySource.StartActivity("Author.Invoke");
activity?.SetTag("blog.revision", state.RevisionNumber);
Expand All @@ -72,7 +72,7 @@ public async Task<string> InvokeAsync(ResearchState state)
{
MaxOutputTokens = _maxOutputTokens,
});
AgentResponse response = await _agent.RunAsync(message, options: runOptions);
AgentResponse response = await _agent.RunAsync(message, options: runOptions, cancellationToken: cancellationToken);
string content = response.Text;
return !string.IsNullOrEmpty(content) ? content : "Draft in progress...";
}
Expand All @@ -83,18 +83,18 @@ public async Task<string> InvokeAsync(ResearchState state)
}
catch (Exception e)
{
Console.WriteLine($"Author error: {e.Message}");
_logger.LogError(e, "Author agent failed to generate content.");
return "Error generating draft. Please try again.";
}
}

/// <summary>Author node that creates or revises draft.</summary>
public async Task<ResearchState> AuthorNodeAsync(ResearchState state)
public async Task<ResearchState> AuthorNodeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
Console.WriteLine("\n>>>Author");
_logger.LogInformation("Author stage started.");

string draft = await InvokeAsync(state);
Console.WriteLine($"Draft created: {draft.Length} characters");
string draft = await InvokeAsync(state, cancellationToken);
_logger.LogInformation("Draft created: {Length} characters", draft.Length);

state.Draft = draft;
state.RevisionNumber += 1;
Expand Down
21 changes: 11 additions & 10 deletions BlogWorkflow.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.Logging;

namespace BlogWriter;

Expand All @@ -15,13 +16,14 @@ public class BlogWorkflow(
IBloggerAgent blogger,
IResearcherAgent researcher,
IAuthorAgent author,
IReviewerAgent reviewer) : IBlogWorkflow
IReviewerAgent reviewer,
ILogger<BlogWorkflow> logger) : IBlogWorkflow
{
// Emits the root span for a workflow run. Activated by the ActivityListener
// registered in Program.cs (or an OpenTelemetry TracerProvider).
private static readonly ActivitySource s_activitySource = new("BlogWriter.Workflow");

public async Task<ResearchState> RunAsync(ResearchState state)
public async Task<ResearchState> RunAsync(ResearchState state, CancellationToken cancellationToken = default)
{
using Activity? activity = s_activitySource.StartActivity("Workflow.Run");
activity?.SetTag("blog.topic", state.MainTask);
Expand All @@ -45,27 +47,26 @@ public async Task<ResearchState> RunAsync(ResearchState state)
// Stream execution instead of running to completion in one shot. The
// topology is identical to before (proven terminating, MAF-Doctor grade A);
// streaming simply surfaces each executor's lifecycle as it happens, giving
// live progress and replacing the scattered Console.WriteLine tracing that
// previously lived inside the node classes. The final ResearchState is
// captured from the WorkflowOutputEvent emitted by the reviewer.
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state);
// live progress. The final ResearchState is captured from the
// WorkflowOutputEvent emitted by the reviewer.
Comment on lines 47 to +51
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state, cancellationToken: cancellationToken);

ResearchState? result = null;

await foreach (WorkflowEvent evt in run.WatchStreamAsync())
await foreach (WorkflowEvent evt in run.WatchStreamAsync().WithCancellation(cancellationToken))
{
switch (evt)
{
case ExecutorInvokedEvent invoked:
Console.WriteLine($"[workflow] → {invoked.ExecutorId} started");
logger.LogInformation("[workflow] -> {ExecutorId} started", invoked.ExecutorId);
break;

case ExecutorCompletedEvent completed:
Console.WriteLine($"[workflow] ✓ {completed.ExecutorId} completed");
logger.LogInformation("[workflow] {ExecutorId} completed", completed.ExecutorId);
break;

case ExecutorFailedEvent failed:
Console.WriteLine($"[workflow] ✗ {failed.ExecutorId} failed: {(failed.Data as Exception)?.Message}");
logger.LogError(failed.Data as Exception, "[workflow] {ExecutorId} failed", failed.ExecutorId);

// A token-cap breach must abort the whole run, not just the
// node. Re-throw it so it unwinds to the application entry point.
Expand Down
20 changes: 20 additions & 0 deletions BlogWriter.Tests/BlogWriter.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

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

</Project>
98 changes: 98 additions & 0 deletions BlogWriter.Tests/BloggerAgentRoutingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace BlogWriter.Tests;

/// <summary>
/// Verifies the Blogger's deterministic C# routing rules never reach the model
/// (a <see cref="ThrowingChatClient"/> fails the test if the LLM path is hit).
/// </summary>
public class BloggerAgentRoutingTests
{
private static BloggerAgent CreateAgent() =>
new(new ThrowingChatClient(), new ChatOptions { Temperature = 0, MaxOutputTokens = 100 }, NullLogger<BloggerAgent>.Instance);

[Fact]
public async Task NoResearch_RoutesToResearcher()
{
BloggerDecision decision = await CreateAgent().InvokeAsync(new ResearchState { MainTask = "topic" });

Assert.Equal("researcher", decision.NextStep);
}

[Fact]
public async Task ResearchButNoDraft_RoutesToAuthor()
{
var state = new ResearchState { MainTask = "topic", ResearchFindings = ["some findings"] };

BloggerDecision decision = await CreateAgent().InvokeAsync(state);

Assert.Equal("author", decision.NextStep);
}

[Fact]
public async Task DraftWithNoReview_RoutesToReviewer()
{
var state = new ResearchState
{
MainTask = "topic",
ResearchFindings = ["some findings"],
Draft = "a draft",
};

BloggerDecision decision = await CreateAgent().InvokeAsync(state);

Assert.Equal("reviewer", decision.NextStep);
}

[Fact]
public async Task DraftWithRevisionFeedback_RoutesBackToAuthor()
{
var state = new ResearchState
{
MainTask = "topic",
ResearchFindings = ["some findings"],
Draft = "a draft",
ReviewNotes = "Please tighten the intro.",
RevisionNumber = 1,
};

BloggerDecision decision = await CreateAgent().InvokeAsync(state);

Assert.Equal("author", decision.NextStep);
}

[Fact]
public async Task ApprovedDraft_Ends()
{
var state = new ResearchState
{
MainTask = "topic",
ResearchFindings = ["some findings"],
Draft = "a draft",
ReviewNotes = ResearchState.ApprovedMarker,
};

BloggerDecision decision = await CreateAgent().InvokeAsync(state);

Assert.Equal("END", decision.NextStep);
}

[Fact]
public async Task RevisionCapReached_Ends()
{
var state = new ResearchState
{
MainTask = "topic",
ResearchFindings = ["some findings"],
Draft = "a draft",
ReviewNotes = "Still not good enough.",
RevisionNumber = ResearchState.MaxRevisions,
};

BloggerDecision decision = await CreateAgent().InvokeAsync(state);

Assert.Equal("END", decision.NextStep);
}
}
53 changes: 53 additions & 0 deletions BlogWriter.Tests/ResearchStateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using BlogWriter;
using Xunit;

namespace BlogWriter.Tests;

public class ResearchStateTests
{
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData("Needs more detail on X.", false)]
[InlineData("APPROVED", true)]
[InlineData("approved - nice work", true)]
[InlineData("APPROVED - Maximum revisions reached.", true)]
public void IsApproved_DetectsMarkerCaseInsensitively(string? reviewNotes, bool expected)
{
Assert.Equal(expected, ResearchState.IsApproved(reviewNotes));
}

[Fact]
public void NeedsRevision_TrueWhenNotApprovedAndUnderCap()
{
var state = new ResearchState { ReviewNotes = "Please revise the intro.", RevisionNumber = 1 };

Assert.True(state.NeedsRevision);
}

[Fact]
public void NeedsRevision_FalseWhenApproved()
{
var state = new ResearchState { ReviewNotes = ResearchState.ApprovedMarker, RevisionNumber = 1 };

Assert.False(state.NeedsRevision);
}

[Fact]
public void NeedsRevision_FalseWhenRevisionCapReached()
{
var state = new ResearchState { ReviewNotes = "Still needs work.", RevisionNumber = ResearchState.MaxRevisions };

Assert.False(state.NeedsRevision);
}

[Fact]
public void NeedsRevision_FalseOneStepBelowCap_TrueWhenBelow()
{
var belowCap = new ResearchState { ReviewNotes = "revise", RevisionNumber = ResearchState.MaxRevisions - 1 };
var atCap = new ResearchState { ReviewNotes = "revise", RevisionNumber = ResearchState.MaxRevisions };

Assert.True(belowCap.NeedsRevision);
Assert.False(atCap.NeedsRevision);
}
}
58 changes: 58 additions & 0 deletions BlogWriter.Tests/TestChatClients.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.Extensions.AI;

namespace BlogWriter.Tests;

/// <summary>Minimal <see cref="IChatClient"/> test double returning a canned response with fixed usage.</summary>
internal sealed class FakeChatClient : IChatClient
{
private readonly Func<UsageDetails?> _usageFactory;

public FakeChatClient(Func<UsageDetails?> usageFactory) => _usageFactory = usageFactory;

public FakeChatClient(long totalTokens) : this(() => new UsageDetails { TotalTokenCount = totalTokens })
{
}

public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))
{
Usage = _usageFactory(),
};
return Task.FromResult(response);
}

public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return new ChatResponseUpdate(ChatRole.Assistant, "ok");
}

public object? GetService(Type serviceType, object? serviceKey = null) => null;

public void Dispose()
{
}
}

/// <summary>An <see cref="IChatClient"/> that fails the test if it is ever invoked.</summary>
internal sealed class ThrowingChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("The model should not have been called for this deterministic routing path.");

public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("The model should not have been called for this deterministic routing path.");

public object? GetService(Type serviceType, object? serviceKey = null) => null;

public void Dispose()
{
}
}
41 changes: 41 additions & 0 deletions BlogWriter.Tests/TokenCapChatClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.Extensions.AI;
using Xunit;

namespace BlogWriter.Tests;

public class TokenCapChatClientTests
{
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Constructor_ThrowsForNonPositiveCap(long invalidCap)
{
using var inner = new FakeChatClient(totalTokens: 0);

Assert.Throws<ArgumentOutOfRangeException>(() => new TokenCapChatClient(inner, invalidCap));
}

[Fact]
public async Task GetResponseAsync_AllowsCallsUnderTheCap()
{
using var inner = new FakeChatClient(totalTokens: 40);
using var client = new TokenCapChatClient(inner, maxTotalTokens: 100);

await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi again")]);

// 80 tokens consumed of a 100 cap — no exception expected.
}

[Fact]
public async Task GetResponseAsync_ThrowsOnceCumulativeUsageExceedsCap()
{
using var inner = new FakeChatClient(totalTokens: 60);
using var client = new TokenCapChatClient(inner, maxTotalTokens: 100);

await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi")]);

await Assert.ThrowsAsync<TokenCapExceededException>(
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi again")]));
}
}
5 changes: 5 additions & 0 deletions BlogWriter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
<UserSecretsId>162971f9-9e16-430b-9879-eef4676fe8c8</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<!-- BlogWriter.Tests is a separate project nested under this folder; exclude it from this project's default glob. -->
<Compile Remove="BlogWriter.Tests\**\*.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.10.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows.Generators" Version="1.10.0">
Expand Down
Loading