In this post, let's take the handoff workflow from the last post and let Microsoft Foundry host it, so the two agents run in Foundry behind a single endpoint.
In the previous post, Microsoft Agent Framework: Handoff Orchestration with .NET, we had an activities agent and a dining agent handing the conversation between them, all inside a console application. And before that, in Microsoft Agent Framework: Hosted Agents in Microsoft Foundry with .NET, we deployed a single agent as a hosted agent. This post is those two put together.
What changes
Surprisingly little. The agents and the handoff wiring stay exactly as they were.
// The model decides who answers next, not us Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(activitiesAgent) .WithHandoffs(activitiesAgent, [diningAgent]) .WithHandoffs(diningAgent, [activitiesAgent]) // Follow-ups stay with the agent already helping, instead of going back to the first one .EnableReturnToPrevious() .Build(); // The whole workflow becomes a single agent AIAgent weekendConcierge = workflow.AsAIAgent( name: "weekend-concierge", description: "Plans a weekend, and hands off between activities and dining.");
What changes is the ending. Instead of running turns ourselves, we serve the workflow over the Responses protocol.
// Serve the whole workflow as one agent, over the Responses protocol AgentHostBuilder builder = AgentHost.CreateBuilder(args); builder.Services.AddFoundryResponses(weekendConcierge); builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses()); AgentHostApp app = builder.Build(); app.Run();
And the two environment values now come from Foundry when the agent is hosted with DefaultAzureCredential so the same code runs as us locally and as the agent's own managed identity in Foundry.
// Foundry injects these when the agent is hosted, locally they come from launchSettings.json string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")!; string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")!; // Locally this is the developer, in Foundry it is the agent's own managed identity var credential = new DefaultAzureCredential();
That's the whole difference. Two agents, one workflow, one hosted agent.
Deploying
Same as the single hosted agent, from the agent's folder.
azd ai agent init --no-prompt `
--src . `
--agent-name weekend-concierge `
--project-id "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>" `
--deploy-mode code `
--runtime dotnet_10 `
--entry-point HostedHandoff.dll `
--model-deployment <DeploymentId> `
--protocol responses `
--environment weekend-concierge
azd deployWatching the handoff from outside
This is the part I like. In the console sample, we could see the handoff because we watched the [Tool] lines scroll past in our own process. Now the agent runs in Foundry, and the handoff still shows up, in the response itself.
@agent = weekend-concierge # For the deployed agent, get a token with: # az account get-access-token --scope https://ai.azure.com/.default --query accessToken --output tsv @endpoint = https://<resource>.services.ai.azure.com/api/projects/<projectId> @token = <token> ### Invoke the deployed agent, the output array shows each agent that ran POST {{endpoint}}/agents/{{agent}}/endpoint/protocols/openai/responses?api-version=v1 Authorization: Bearer {{token}} Content-Type: application/json { "input": "Where can we eat in Auckland this weekend?", "stream": false }
The output array of the response contains workflow_action items for each agent that ran, and function_call items for the handoff and the tools.
{
"id": "caresp_0a6ccab0dcd4e167003Gt1Y2T1IDmnmWlyqnqEC9wodjXo0F20",
"object": "response",
"status": "completed",
"output": [
// Omitted for brevity
{
"type": "workflow_action",
"action_id": "HandoffStart",
"status": "completed"
},
{
"type": "workflow_action",
"action_id": "activities_5fcc719d6e2b4134a0d92ab2df3988bd",
"status": "in_progress"
},
{
"type": "function_call",
"call_id": "call_FzQP62eu0leGjPL1cykdGEt4",
"name": "handoff_to_1",
"arguments": "{\"reasonForHandoff\":\"User is asking for places to eat (restaurants) in Auckland this weekend.\"}"
},
{
"type": "function_call_output",
"call_id": "call_FzQP62eu0leGjPL1cykdGEt4",
"output": "Transferred."
},
{
"type": "workflow_action",
"action_id": "activities_5fcc719d6e2b4134a0d92ab2df3988bd",
"status": "completed"
},
{
"type": "workflow_action",
"action_id": "dining_e079cd98ffb646febbf349ae1a4ba1ff",
"status": "in_progress"
},
{
"type": "function_call",
"name": "GetRestaurants",
"arguments": "{\"city\":\"Auckland\"}"
},
{
"type": "function_call_output",
"output": "[{\"name\":\"The Domain Kitchen\",\"cuisine\":\"Modern New Zealand\"}]"
// Omitted for brevity
},
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Here are a few options in Auckland for this weekend: The Domain Kitchen - Modern New Zealand"
// Omitted for brevity
}
]
},
{
"type": "workflow_action",
"action_id": "dining_e079cd98ffb646febbf349ae1a4ba1ff",
"status": "completed"
},
// Omitted for brevity
{
"type": "workflow_action",
"action_id": "HandoffEnd",
"status": "completed"
}
],
"agent": {
"type": "agent_id",
"name": "weekend-concierge",
"version": "2"
}
// Omitted for brevity
}
The question was about food, so the activities agent handed straight over to dining, which looked up the restaurants. Worth noting the handoff is just a tool call like any other, and its arguments carry the model's own reasonForHandoff, so we get to see why it moved the conversation. The tool result is simply Transferred. The same thing is visible in the agent's playground in the Foundry portal, which makes it quite easy to show someone how the routing behaves.
Can the agents be prompt agents?
This was the first thing I wanted to try. The two agents here are created in code, so their instructions ship with the container. It would be nicer if they were prompt agents, because then someone could edit their instructions in the portal while the handoff wiring stays in the repository.
So I tried it, registering the two agents as prompt agents with their function tools declared in the definition, then passing the tools in so they still run locally.
// The tools run here, the definitions come from Foundry AIAgent activitiesAgent = projectClient.AsAIAgent(activitiesVersion, tools: activitiesTools); AIAgent diningAgent = projectClient.AsAIAgent(diningVersion, tools: diningTools);
Their own tools work fine, GetWeather and the rest are called and executed locally as expected. But the handoff never happens. Asked to hand over, the agent writes it out as text instead of calling the tool.
agent: weekend_activities_prompt_weekend_activities_prompt_1 MSG: Handoff to dining agent: User wants a restaurant recommendation in Auckland agent: HandoffEnd
The reason is how handoff works. As the Handoff Agent Executor section describes, the workflow injects an extra tool into each agent for every agent it can hand over to. A prompt agent doesn't take tools that way. Per Microsoft Foundry Agent Service, a FoundryAgent uses the model, instructions and hosted tools stored in its Foundry definition, and the client can't replace them at run time. So the injected handoff tools never reach the model, and all it can do is describe the handoff in text. Handoff participants need to be agents created in code.
And when you are done with it, delete the agent weekend-concierge, from the portal or over REST. As in the hosted agent post, azd down is not what you want here, the project was already there before we deployed into it.
The complete sample is here.
More read:
Host Microsoft Agent Framework agents as Foundry hosted agents
Microsoft Agent Framework Workflows Orchestrations - Handoff
What are hosted agents?
Microsoft Agent Framework Workflows Orchestrations - Handoff
What are hosted agents?
Hope this helps.
Happy Coding.
Regards,
Jaliya
No comments:
Post a Comment