diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile new file mode 100644 index 0000000000..24585dec12 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor new file mode 100644 index 0000000000..91a403c26c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Abstractions source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +# docker run --rm -p 8088:8088 hosted-invocations-echo-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs new file mode 100644 index 0000000000..ccbfe72781 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoAIAgent.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// A minimal that echoes the user's input text back as the response. +/// No LLM or external service is required. +/// +public sealed class EchoAIAgent : AIAgent +{ + /// + public override string Name => "echo-agent"; + + /// + public override string Description => "An agent that echoes back the input message."; + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"Echo: {inputText}")); + return Task.FromResult(response); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent($"Echo: {inputText}")], + }; + + await Task.CompletedTask; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + private static string GetInputText(IEnumerable messages) + { + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + return message.Text ?? string.Empty; + } + } + + return string.Empty; + } + + /// + /// Minimal session for the echo agent. No state is persisted. + /// + private sealed class EchoAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs new file mode 100644 index 0000000000..9834e7577a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using Microsoft.Agents.AI; +using Microsoft.AspNetCore.Http; + +namespace HostedInvocationsEchoAgent; + +/// +/// An that reads the request body as plain text, +/// passes it to the , and writes the response back. +/// +public sealed class EchoInvocationHandler(EchoAIAgent agent) : InvocationHandler +{ + /// + public override async Task HandleAsync( + HttpRequest request, + HttpResponse response, + InvocationContext context, + CancellationToken cancellationToken) + { + // Read the raw text from the request body. + using var reader = new StreamReader(request.Body); + var input = await reader.ReadToEndAsync(cancellationToken); + + // Run the echo agent with the input text. + var agentResponse = await agent.RunAsync(input, cancellationToken: cancellationToken); + + // Write the agent response text back to the HTTP response. + response.ContentType = "text/plain"; + await response.WriteAsync(agentResponse.Text, cancellationToken); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj new file mode 100644 index 0000000000..b84faccf9e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + HostedInvocationsEchoAgent + HostedInvocationsEchoAgent + $(NoWarn); + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs new file mode 100644 index 0000000000..1650253dfc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Program.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using HostedInvocationsEchoAgent; +using Microsoft.Agents.AI; + +var builder = WebApplication.CreateBuilder(args); + +// Register the echo agent as a singleton (no LLM needed). +builder.Services.AddSingleton(); + +// Register the Invocations SDK services and wire the handler. +builder.Services.AddInvocationsServer(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Map the Invocations protocol endpoints: +// POST /invocations — invoke the agent +// GET /invocations/{id} — get result (not used by this sample) +// POST /invocations/{id}/cancel — cancel (not used by this sample) +app.MapInvocationsServer(); + +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..7722815b12 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Hosted-Invocations-EchoAgent": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:8088" + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md new file mode 100644 index 0000000000..7133c43cf1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/README.md @@ -0,0 +1,66 @@ +# Hosted-Invocations-EchoAgent + +A minimal echo agent hosted as a Foundry Hosted Agent using the **Invocations protocol**. The agent reads the request body as plain text, passes it through a custom `EchoAIAgent`, and writes the echoed text back in the response. No LLM or Azure credentials are required. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello, world!" +``` + +Expected response: + +``` +Echo: Hello, world! +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +``` + +### 3. Run the container + +```bash +docker run --rm -p 8088:8088 hosted-invocations-echo-agent +``` + +### 4. Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello from Docker!" +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml new file mode 100644 index 0000000000..09e4b0f885 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.manifest.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-invocations-echo-agent +displayName: "Hosted Invocations Echo Agent" + +description: > + A minimal echo agent hosted as a Foundry Hosted Agent using the Invocations + protocol. Reads the request body as plain text, echoes it back in the response. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Invocations Protocol + - Agent Framework + +template: + name: hosted-invocations-echo-agent + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml new file mode 100644 index 0000000000..001a19f0ac --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Hosted-Invocations-EchoAgent/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-invocations-echo-agent +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs new file mode 100644 index 0000000000..54f8022d1b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// An that invokes a remote agent hosted with the Invocations protocol +/// by sending plain-text HTTP POST requests to the /invocations endpoint. +/// +public sealed class InvocationsAIAgent : AIAgent +{ + private readonly HttpClient _httpClient; + private readonly Uri _invocationsUri; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The base URI of the hosted agent (e.g., http://localhost:8089). + /// The /invocations path is appended automatically. + /// + /// Optional to use. If , a new instance is created. + /// Optional name for the agent. + /// Optional description for the agent. + public InvocationsAIAgent( + Uri agentEndpoint, + HttpClient? httpClient = null, + string? name = null, + string? description = null) + { + ArgumentNullException.ThrowIfNull(agentEndpoint); + + this._httpClient = httpClient ?? new HttpClient(); + + // Ensure the base URI ends with a slash so that combining works correctly. + var baseUri = agentEndpoint.AbsoluteUri.EndsWith('/') + ? agentEndpoint + : new Uri(agentEndpoint.AbsoluteUri + "/"); + this._invocationsUri = new Uri(baseUri, "invocations"); + + this.Name = name ?? "invocations-agent"; + this.Description = description ?? "An agent that calls a remote Invocations protocol endpoint."; + } + + /// + public override string? Name { get; } + + /// + public override string? Description { get; } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetLastUserText(messages); + var responseText = await SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, responseText)); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // The Invocations protocol returns a complete response (no SSE streaming), + // so we yield a single update with the full text. + var inputText = GetLastUserText(messages); + var responseText = await SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(responseText)], + }; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + private async Task SendInvocationAsync(string input, CancellationToken cancellationToken) + { + using var content = new StringContent(input, System.Text.Encoding.UTF8, "text/plain"); + using var response = await this._httpClient.PostAsync(this._invocationsUri, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + private static string GetLastUserText(IEnumerable messages) + { + string? lastUserText = null; + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + lastUserText = message.Text; + } + } + + return lastUserText ?? string.Empty; + } + + /// + /// Minimal session for the invocations agent. No state is persisted. + /// + private sealed class InvocationsAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs new file mode 100644 index 0000000000..915e73737d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/Program.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using DotNetEnv; +using Microsoft.Agents.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +// Create an agent that calls the remote Invocations endpoint. +InvocationsAIAgent agent = new(agentEndpoint); + +// REPL +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Simple Invocations Agent Sample + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj new file mode 100644 index 0000000000..d509bd2c70 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/HostedAgentsV2/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + false + SimpleInvocationsAgentClient + simple-invocations-agent-client + $(NoWarn);NU1903;NU1605 + + + + + + + + + + +