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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
**/bin/
**/obj/
config.json
.env
.env.*
!.env.example
.claude/
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ If none of the above fits, call `MafTour()` for the full capability
catalogue (every tool / prompt / resource / agent, one line each), or run
the `maf-help` prompt for guided 3-question triage.
<!-- END maf-doctor -->

## Microsoft Foundry hosted agents

This project was built with the microsoft-foundry skill. Before working on or answering questions about Foundry agents, read the microsoft-foundry skill first. If you are in VS Code, read the vscode-microsoft-foundry skill first.
27 changes: 3 additions & 24 deletions AuthorAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,11 @@ public class AuthorAgent : IAuthorAgent

private readonly ILogger<AuthorAgent> _logger;

// Per-call output-token cap, applied on each RunAsync to bound cost.
private readonly int? _maxOutputTokens;

public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger<AuthorAgent> logger)
public AuthorAgent(AIAgent agent, ILogger<AuthorAgent> logger)
{
_logger = logger;
_maxOutputTokens = chatOptions.MaxOutputTokens;

_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Author",
ChatOptions = new ChatOptions
{
Instructions = Prompts.AuthorInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
})
.AsBuilder()
.UseOpenTelemetry(sourceName: "BlogWriter.Agents")
.Build();
_agent = agent;
_logger.LogInformation("AuthorAgent initialized.");
}
Comment on lines +24 to 30

Expand Down Expand Up @@ -69,12 +53,7 @@ public AuthorAgent(IChatClient llm, ChatOptions chatOptions, ILogger<AuthorAgent

try
{
// Cap per-call output tokens so a single turn can't blow the cost budget.
ChatClientAgentRunOptions runOptions = new(new ChatOptions
{
MaxOutputTokens = _maxOutputTokens,
});
AgentResponse response = await _agent.RunAsync(message, options: runOptions, cancellationToken: cancellationToken);
AgentResponse response = await _agent.RunAsync(message, cancellationToken: cancellationToken);
string content = response.Text;
if (!string.IsNullOrEmpty(content))
{
Expand Down
14 changes: 14 additions & 0 deletions BlogWriter.Tests/ResearchStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,18 @@ public void NeedsRevision_FalseOneStepBelowCap_TrueWhenBelow()
Assert.True(belowCap.NeedsRevision);
Assert.False(atCap.NeedsRevision);
}

[Theory]
[InlineData("APPROVED", ResearchState.MaxRevisions, false)]
[InlineData("Still needs work.", ResearchState.MaxRevisions, true)]
[InlineData("Still needs work.", ResearchState.MaxRevisions - 1, false)]
public void RevisionLimitReached_RequiresUnapprovedReviewAtCap(
string reviewNotes,
int revisionNumber,
bool expected)
{
var state = new ResearchState { ReviewNotes = reviewNotes, RevisionNumber = revisionNumber };

Assert.Equal(expected, state.RevisionLimitReached);
}
}
13 changes: 13 additions & 0 deletions BlogWriter.Tests/TokenCapChatClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,17 @@ public async Task GetResponseAsync_ThrowsOnceCumulativeUsageExceedsCap()
await Assert.ThrowsAsync<TokenCapExceededException>(
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "hi again")]));
}

[Fact]
public async Task SharedFactory_EnforcesOneBudgetAcrossClients()
{
Func<IChatClient, IChatClient> factory = TokenCapChatClient.CreateSharedFactory(maxTotalTokens: 100);
using IChatClient firstClient = factory(new FakeChatClient(totalTokens: 60));
using IChatClient secondClient = factory(new FakeChatClient(totalTokens: 60));

await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, "first agent")]);

await Assert.ThrowsAsync<TokenCapExceededException>(
() => secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, "second agent")]));
}
}
15 changes: 10 additions & 5 deletions BlogWriter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@
</PropertyGroup>

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

<ItemGroup>
<!-- Entra ID auth for RemoteHostedAgentChatClient, which calls each Foundry Hosted
Agent's OpenAI-compatible /responses endpoint over a plain HttpClient. -->
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.20.0-preview.260831.1" />
</ItemGroup>

<ItemGroup>
Expand All @@ -21,15 +29,12 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.7.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.7.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.10" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="OpenAI" Version="2.11.0" />
</ItemGroup>

</Project>
35 changes: 12 additions & 23 deletions BloggerAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,22 @@ public class BloggerAgent : IBloggerAgent

private readonly ILogger<BloggerAgent> _logger;

// Per-call output-token cap, applied on each RunAsync to bound cost.
private readonly int? _maxOutputTokens;

public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger<BloggerAgent> logger)
public BloggerAgent(AIAgent agent, ILogger<BloggerAgent> logger)
{
_logger = logger;
_maxOutputTokens = chatOptions.MaxOutputTokens;

_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
_agent = agent;
_logger.LogInformation("BloggerAgent initialized.");
}

// Compatibility overload for callers that supply an in-process test client.
public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger<BloggerAgent> logger)
: this(new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Blogger",
ChatOptions = new ChatOptions
{
Instructions = Prompts.BloggerInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
})
.AsBuilder()
.UseOpenTelemetry(sourceName: "BlogWriter.Agents")
.Build();
_logger.LogInformation("BloggerAgent initialized.");
ChatOptions = chatOptions,
Comment on lines +42 to +46
}), logger)
{
}

public async Task<BloggerDecision> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -117,13 +111,8 @@ public async Task<BloggerDecision> InvokeAsync(ResearchState state, Cancellation

try
{
// Cap per-call output tokens so a single turn can't blow the cost budget.
ChatClientAgentRunOptions runOptions = new(new ChatOptions
{
MaxOutputTokens = _maxOutputTokens,
});
AgentResponse<BloggerDecision> response =
await _agent.RunAsync<BloggerDecision>(stateSummary, options: runOptions, serializerOptions: _jsonOptions, cancellationToken: cancellationToken);
await _agent.RunAsync<BloggerDecision>(stateSummary, serializerOptions: _jsonOptions, cancellationToken: cancellationToken);

BloggerDecision decision = response.Result;
if (decision is not null && !string.IsNullOrEmpty(decision.NextStep))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Drafts and revises complete professional blog posts from research findings and reviewer feedback.
1 change: 1 addition & 0 deletions HostedAgents/Author/.agent_configs/baseline/metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
instruction_file: instructions.md
43 changes: 43 additions & 0 deletions HostedAgents/Author/.agentignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Files excluded from agent code deployment packaging.
# Uses .gitignore syntax.
# Note: only the root .agentignore is read; subdirectory files are not supported.
#
# To include a file that is excluded by default, use negation: !filename

# azd tooling files
agent.yaml
agent.manifest.yaml
azure.yaml
.agentignore
appPackage.zip
.appPackage.zip.azd-generated
TEAMS_APP_SETUP.md

# Security / secrets
.env
.env.*
.azure/
.git/

# Python
__pycache__/
.venv/
venv/
*.pyc
*.pyo
.mypy_cache/
.pytest_cache/

# .NET
bin/
obj/
*.user
*.suo
.vs/

# Node
node_modules/

# Docker (not used in code deploy)
Dockerfile
.dockerignore
19 changes: 19 additions & 0 deletions HostedAgents/Author/AgentPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace BlogWriter;

public static class Prompts
{
public const string AuthorInstructions = """
You are a professional blogger.

The user message contains the main task, the research findings, the current
draft (if any), any reviewer notes, and the target word count range.

Instructions:
- If this is the first draft (no current draft), create a comprehensive post based on the findings
- If there is a current draft and review notes, revise the draft to address all feedback
- Use a professional tone
- Aim for the target word count range given in the user message.

Write the complete post.
""";
}
19 changes: 19 additions & 0 deletions HostedAgents/Author/Author.HostedAgent.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>BlogWriter.HostedAgents.Author</AssemblyName>
<RootNamespace>BlogWriter.HostedAgents.Author</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>1288dcff-4dfa-44b7-a6d3-30ff6c988520</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.20.0-preview.260831.1" />
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
</ItemGroup>

</Project>
28 changes: 28 additions & 0 deletions HostedAgents/Author/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Azure.AI.AgentServer.Core;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;

// Foundry Hosted Agent for the "Author" role. Deployed independently
// (azd ai agent init / azd provision / azd deploy — see README) and referenced
// by name from the BlogWriter console app.

var projectEndpoint = new Uri(
Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
string modelDeployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5-mini";

// Entra ID only — no API keys, per repository constraint.
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
.AsAIAgent(
model: modelDeployment,
instructions: BlogWriter.Prompts.AuthorInstructions,
name: "Author");

var builder = AgentHost.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses());

var app = builder.Build();
app.Run();
16 changes: 16 additions & 0 deletions HostedAgents/Author/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Author — Foundry Hosted Agent

Hosts the Author role (drafts/revises the post) as an Azure AI Foundry Hosted
Agent. See `../README.md` for the shared `azd` deploy flow.

## Configuration

Set before `azd ai agent run` / `azd deploy` (env vars, or via `azd env set`):

| Key | Required | Default |
| --- | --- | --- |
| `FOUNDRY_PROJECT_ENDPOINT` | yes | — |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | no | `gpt-5-mini` |

Instructions are compiled from the deployment-local `AgentPrompt.cs`. Keep it
aligned with `../../Prompts.cs` when changing the Author prompt.
31 changes: 31 additions & 0 deletions HostedAgents/Author/azure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json

name: blogwriter-hosted-agent-author
metadata:
template: blogwriter-hosted-agent
services:
ai-project:
host: azure.ai.project
blogwriter-author:
project: .
host: azure.ai.agent
language: csharp
uses:
- ai-project
description: Drafts and revises blog posts.
codeConfiguration:
dependencyResolution: remote_build
entryPoint: BlogWriter.HostedAgents.Author.dll
runtime: dotnet_10
container:
resources:
cpu: "0.5"
memory: 1Gi
kind: hosted
name: blogwriter-author
environmentVariables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
protocols:
- protocol: responses
version: 2.0.0
8 changes: 8 additions & 0 deletions HostedAgents/Author/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: smoke-core
agent:
name: blogwriter-author
kind: hosted
config: .agent_configs\baseline\metadata.yaml
options:
eval_model: gpt-5-mini
max_samples: 15
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Routes the BlogWriter workflow to the next best step based on current research, draft, review status, and revision count.
1 change: 1 addition & 0 deletions HostedAgents/Blogger/.agent_configs/baseline/metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
instruction_file: instructions.md
Loading