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
@@ -1,9 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.

using System.ClientModel;
using System.Text;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.AzureAI;
using Microsoft.SemanticKernel.Agents.OpenAI;
using OpenAI;
Expand Down Expand Up @@ -33,77 +34,137 @@ public Step06_FoundryAgentProcess(ITestOutputHelper output) : base(output, redir
[Fact]
public async Task ProcessWithTwoAgentMathChat()
{
// Define the agents. IMPORTANT: replace with your own agent IDs
var studentDefinition = new AgentDefinition { Id = "{YOUR_STUDENT_AGENT_ID}", Name = "Student", Type = AzureAIAgentFactory.AzureAIAgentType };
var teacherDefinition = new AgentDefinition { Id = "{YOUR_TEACHER_AGENT_ID}", Name = "Teacher", Type = AzureAIAgentFactory.AzureAIAgentType };

// Define the process with a state type
var processBuilder = new FoundryProcessBuilder<TwoAgentMathState>("two_agent_math_chat");

// Create a thread for the student
processBuilder.AddThread("Student", KernelProcessThreadLifetime.Scoped);
processBuilder.AddThread("Teacher", KernelProcessThreadLifetime.Scoped);

// Add the student
var student = processBuilder.AddStepFromAgent(studentDefinition);

// Add the teacher
var teacher = processBuilder.AddStepFromAgent(teacherDefinition);

/**************************** Orchestrate ***************************/

// When the process starts, activate the student agent
processBuilder.OnProcessEnter().SendEventTo(
student,
thread: "_variables_.Student",
messagesIn: ["_variables_.TeacherMessages"],
inputs: new Dictionary<string, string>
{
{ "InteractionCount", "_variables_.StudentState.InteractionCount" }
});

// When the student agent exits, update the process state to save the student's messages and update interaction counts
processBuilder.OnStepExit(student)
.UpdateProcessState(path: "StudentMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out")
.UpdateProcessState(path: "InteractionCount", operation: StateUpdateOperations.Increment, value: 1)
.UpdateProcessState(path: "StudentState.InteractionCount", operation: StateUpdateOperations.Increment, value: 1)
.UpdateProcessState(path: "StudentState.Name", operation: StateUpdateOperations.Set, value: "Runhan");

// When the student agent is finished, send the messages to the teacher agent
processBuilder.OnEvent(student, "_default_")
.SendEventTo(teacher, messagesIn: ["_variables_.StudentMessages"], thread: "Teacher");

// When the teacher agent exits with a message containing '[COMPLETE]', update the process state to save the teacher's messages and update interaction counts and emit the `correct_answer` event
processBuilder.OnStepExit(teacher, condition: "contains(to_string(_agent_.messages_out), '[COMPLETE]')")
.EmitEvent(
eventName: "correct_answer",
payload: new Dictionary<string, string>
{
var endpoint = TestConfiguration.AzureAI.Endpoint;
PersistentAgentsClient client = new(endpoint.TrimEnd('/'), new DefaultAzureCredential(), new PersistentAgentsAdministrationClientOptions().WithPolicy(endpoint, "2025-05-15-preview"));

Azure.Response<PersistentAgent>? studentAgent = null;
Azure.Response<PersistentAgent>? teacherAgent = null;

try
{
// Create the single agents
studentAgent = await client.Administration.CreateAgentAsync(
model: "gpt-4o",
name: "Student",
instructions: "You are a student that answer question from teacher, when teacher gives you question you answer them."
);

teacherAgent = await client.Administration.CreateAgentAsync(
model: "gpt-4o",
name: "Teacher",
instructions: "You are a teacher that create pre-school math question for student and check answer.\nIf the answer is correct, you stop the conversation by saying [COMPLETE].\nIf the answer is wrong, you ask student to fix it."
);

// Define the process with a state type
var processBuilder = new FoundryProcessBuilder<TwoAgentMathState>("two_agent_math_chat");

// Create a thread for the student
processBuilder.AddThread("Student", KernelProcessThreadLifetime.Scoped);
processBuilder.AddThread("Teacher", KernelProcessThreadLifetime.Scoped);

// Add the student
var student = processBuilder.AddStepFromAgent(studentAgent);

// Add the teacher
var teacher = processBuilder.AddStepFromAgent(teacherAgent);

/**************************** Orchestrate ***************************/

// When the process starts, activate the student agent
processBuilder.OnProcessEnter().SendEventTo(
student,
thread: "_variables_.Student",
messagesIn: ["_variables_.TeacherMessages"],
inputs: new Dictionary<string, string> { });

// When the student agent exits, update the process state to save the student's messages and update interaction counts
processBuilder.OnStepExit(student)
.UpdateProcessState(path: "StudentMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out");

// When the student agent is finished, send the messages to the teacher agent
processBuilder.OnEvent(student, "_default_")
.SendEventTo(teacher, messagesIn: ["_variables_.StudentMessages"], thread: "Teacher");

// When the teacher agent exits with a message containing '[COMPLETE]', update the process state to save the teacher's messages and update interaction counts and emit the `correct_answer` event
processBuilder.OnStepExit(teacher, condition: "jmespath(contains(to_string(_agent_.messages_out), '[COMPLETE]'))")
.EmitEvent(
eventName: "correct_answer",
payload: new Dictionary<string, string>
{
{ "Question", "_variables_.TeacherMessages" },
{ "Answer", "_variables_.StudentMessages" }
})
.UpdateProcessState(path: "_variables_.TeacherMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out")
.UpdateProcessState(path: "_variables_.InteractionCount", operation: StateUpdateOperations.Increment, value: 1);

// When the teacher agent exits with a message not containing '[COMPLETE]', update the process state to save the teacher's messages and update interaction counts
processBuilder.OnStepExit(teacher, condition: "_default_")
.UpdateProcessState(path: "_variables_.TeacherMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out")
.UpdateProcessState(path: "_variables_.InteractionCount", operation: StateUpdateOperations.Increment, value: 1);
})
.UpdateProcessState(path: "_variables_.TeacherMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out");

// When the teacher agent exits with a message not containing '[COMPLETE]', update the process state to save the teacher's messages and update interaction counts
processBuilder.OnStepExit(teacher, condition: "_default_")
.UpdateProcessState(path: "_variables_.TeacherMessages", operation: StateUpdateOperations.Set, value: "_agent_.messages_out");

// When the teacher agent is finished, send the messages to the student agent
processBuilder.OnEvent(teacher, "_default_", condition: "_default_")
.SendEventTo(student, messagesIn: ["_variables_.TeacherMessages"], thread: "Student");

// When the teacher agent emits the `correct_answer` event, stop the process
processBuilder.OnEvent(teacher, "correct_answer")
.StopProcess();

// Verify that the process can be built and serialized to json
var processJson = await processBuilder.ToJsonAsync();
Assert.NotEmpty(processJson);

var content = await RunWorkflowAsync(client, processBuilder, [new(MessageRole.User, "Go")]);
Assert.NotEmpty(content);
}
finally
{
// Clean up the agents
await client.Administration.DeleteAgentAsync(studentAgent?.Value.Id);
await client.Administration.DeleteAgentAsync(teacherAgent?.Value.Id);
}
}

// When the teacher agent is finished, send the messages to the student agent
processBuilder.OnEvent(teacher, "_default_", condition: "_default_")
.SendEventTo(student, messagesIn: ["_variables_.TeacherMessages"], thread: "Student");
private async Task<string> RunWorkflowAsync<T>(PersistentAgentsClient client, FoundryProcessBuilder<T> processBuilder, List<ThreadMessageOptions>? initialMessages = null) where T : class, new()
{
Workflow? workflow = null;
StringBuilder output = new();

// When the teacher agent emits the `correct_answer` event, stop the process
processBuilder.OnEvent(teacher, "correct_answer")
.StopProcess();
try
{
// publish the workflow
workflow = await client.Administration.Pipeline.PublishWorkflowAsync(processBuilder);

// Verify that the process can be built and serialized to json
var processJson = await processBuilder.ToJsonAsync();
Assert.NotEmpty(processJson);
// threadId is used to store the thread ID
PersistentAgentThread thread = await client.Threads.CreateThreadAsync(messages: initialMessages ?? []);

var foundryWorkflowId = await processBuilder.DeployToFoundryAsync(TestConfiguration.AzureAI.WorkflowEndpoint);
Assert.NotEmpty(foundryWorkflowId);
// create run
await foreach (var run in client.Runs.CreateRunStreamingAsync(thread.Id, workflow.Id))
{
if (run is Azure.AI.Agents.Persistent.MessageContentUpdate contentUpdate)
{
output.Append(contentUpdate.Text);
Console.Write(contentUpdate.Text);
}
else if (run is Azure.AI.Agents.Persistent.RunUpdate runUpdate)
{
if (runUpdate.UpdateKind == Azure.AI.Agents.Persistent.StreamingUpdateReason.RunInProgress && !runUpdate.Value.Id.StartsWith("wf_run", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine();
Console.Write($"{runUpdate.Value.Metadata["x-agent-name"]}> ");
}
}
}

// delete thread, so we can start over
Console.WriteLine($"\nDeleting thread {thread?.Id}...");
await client.Threads.DeleteThreadAsync(thread?.Id);
return output.ToString();
}
finally
{
// // delete workflow
Console.WriteLine($"Deleting workflow {workflow?.Id}...");
await client.Administration.Pipeline.DeleteWorkflowAsync(workflow!);
}
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Agents/AzureAI/Agents.AzureAI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/azure/Policies/GeneratedActionPipelinePolicy.cs" Link="%(RecursiveDir)Azure/%(Filename)%(Extension)" />
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/planning/Schema/JsonSchemaFunctionParameters.cs" Link="%(RecursiveDir)Schema/%(Filename)%(Extension)" />
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/src/Diagnostics/*" Link="%(RecursiveDir)Utilities/%(Filename)%(Extension)" />
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/src/Http/*" Link="%(RecursiveDir)Http/%(Filename)%(Extension)" />
Expand All @@ -31,6 +30,7 @@
</ItemGroup>

<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/agents/AgentUtilities.props" />
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/azure/AzureAIUtilities.props" />

<ItemGroup>
<ProjectReference Include="..\Abstractions\Agents.Abstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using Azure.AI.Agents.Persistent;
using Azure.Core;

namespace Microsoft.SemanticKernel.Agents.AzureAI;

/// <summary>
/// Extensions for configuring the PersistentAgentsAdministrationClientOptions with a routing policy for Foundry Workflows.
/// </summary>
public static class FoundryWorkflowExtensions
{
/// <summary>
/// Adds a routing policy to the PersistentAgentsAdministrationClientOptions for Foundry Workflows.
/// </summary>
/// <param name="options"></param>
/// <param name="endpoint"></param>
/// <param name="apiVersion"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static PersistentAgentsAdministrationClientOptions WithPolicy(this PersistentAgentsAdministrationClientOptions options, string endpoint, string apiVersion)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var _endpoint))
{
throw new ArgumentException("The endpoint must be an absolute URI.", nameof(endpoint));
}

options.AddPolicy(new HttpPipelineRoutingPolicy(_endpoint, apiVersion), HttpPipelinePosition.PerCall);

return options;
}
}
40 changes: 37 additions & 3 deletions dotnet/src/Experimental/Process.Core/FoundryProcessBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Azure.Core;
using Azure.Identity;
using Microsoft.SemanticKernel.Agents;
Expand Down Expand Up @@ -50,19 +51,42 @@ public ProcessBuilder AddThread(string threadName, KernelProcessThreadLifetime t
/// Adds a step to the process from a declarative agent.
/// </summary>
/// <param name="agentDefinition">The <see cref="AgentDefinition"/></param>
/// <param name="id">The unique Id of the step. If not provided, the name of the step Type will be used.</param>
/// <param name="stepId">The unique Id of the step. If not provided, the name of the step Type will be used.</param>
/// <param name="aliases">Aliases that have been used by previous versions of the step, used for supporting backward compatibility when reading old version Process States</param>
/// <param name="defaultThread">Specifies the thread reference to be used by the agent. If not provided, the agent will create a new thread for each invocation.</param>
/// <param name="humanInLoopMode">Specifies the human-in-the-loop mode for the agent. If not provided, the default is <see cref="HITLMode.Never"/>.</param>
public ProcessAgentBuilder<TProcessState> AddStepFromAgent(AgentDefinition agentDefinition, string? id = null, IReadOnlyList<string>? aliases = null, string? defaultThread = null, HITLMode humanInLoopMode = HITLMode.Never)
public ProcessAgentBuilder<TProcessState> AddStepFromAgent(AgentDefinition agentDefinition, string? stepId = null, IReadOnlyList<string>? aliases = null, string? defaultThread = null, HITLMode humanInLoopMode = HITLMode.Never)
{
Verify.NotNull(agentDefinition);
if (agentDefinition.Type != AzureAIAgentFactory.AzureAIAgentType)
{
throw new ArgumentException($"The agent type '{agentDefinition.Type}' is not supported. Only '{AzureAIAgentFactory.AzureAIAgentType}' is supported.");
}

return this._processBuilder.AddStepFromAgent<TProcessState>(agentDefinition, id, aliases, defaultThread, humanInLoopMode);
return this._processBuilder.AddStepFromAgent<TProcessState>(agentDefinition, stepId, aliases, defaultThread, humanInLoopMode);
}

/// <summary>
/// Adds a step to the process from a <see cref="PersistentAgent"/>.
/// </summary>
/// <param name="persistentAgent">The <see cref="AgentDefinition"/></param>
/// <param name="stepId">The unique Id of the step. If not provided, the name of the step Type will be used.</param>
/// <param name="aliases">Aliases that have been used by previous versions of the step, used for supporting backward compatibility when reading old version Process States</param>
/// <param name="defaultThread">Specifies the thread reference to be used by the agent. If not provided, the agent will create a new thread for each invocation.</param>
/// <param name="humanInLoopMode">Specifies the human-in-the-loop mode for the agent. If not provided, the default is <see cref="HITLMode.Never"/>.</param>
public ProcessAgentBuilder<TProcessState> AddStepFromAgent(PersistentAgent persistentAgent, string? stepId = null, IReadOnlyList<string>? aliases = null, string? defaultThread = null, HITLMode humanInLoopMode = HITLMode.Never)
{
Verify.NotNull(persistentAgent);

var agentDefinition = new AgentDefinition
{
Id = persistentAgent.Id,
Type = AzureAIAgentFactory.AzureAIAgentType,
Name = persistentAgent.Name,
Description = persistentAgent.Description
};

return this._processBuilder.AddStepFromAgent<TProcessState>(agentDefinition, stepId, aliases, defaultThread, humanInLoopMode);
}

/// <summary>
Expand Down Expand Up @@ -205,6 +229,16 @@ public async Task<string> ToJsonAsync()
return WorkflowSerializer.SerializeToJson(workflow);
}

/// <summary>
/// Serializes the process to YAML.
/// </summary>
public async Task<string> ToYamlAsync()
{
var process = this.Build();
var workflow = await WorkflowBuilder.BuildWorkflow(process).ConfigureAwait(false);
return WorkflowSerializer.SerializeToYaml(workflow);
}

private class FoundryWorkflow
{
[JsonPropertyName("id")]
Expand Down
Loading
Loading