Skip to content

Improvements to Copilot SDK + Hosted Agent sample #803

Description

@SteveSandersonMS

As discussed with Lakshmi, Glenn, and others, here's a proposal for some ways to improve the samples for Copilot SDK + Hosted Agents.

Currently there's only a sample for that in Python, so here I'm giving an example in C# that I hope can be added, plus the existing Python sample can be similarly updated.

Note the // IMPROVEMENT and // SUGGESTION comments. These shouldn't end up in the final sample - I'm putting those here to call out ways the code here differs from the existing Python sample for clarity.

// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable GHCP001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

/*
 * Hello World — Copilot SDK for C#
 *
 * This sample demonstrates integration with GitHub Copilot SDK, backed by a Foundry model.
 * 
 * Copilot SDK provides a state-of-the-art agent harness - the same agent that powers
 * Copilot CLI. For more information on options and usage, see https://github.com/github/copilot-sdk
 *
 * Required environment variables:
 *   FOUNDRY_PROJECT_ENDPOINT  — Foundry project endpoint (auto-injected in hosted containers)
 *   AZURE_AI_MODEL_DEPLOYMENT_NAME     — Model deployment name (declared in agent.manifest.yaml)
 *
 * Usage:
 *   dotnet run
 *
 *   # Turn 1 — start a new conversation:
 *   curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \
 *     -H "Content-Type: application/json" \
 *     -d '{"message": "What is Microsoft Foundry?"}'
 *
 *   # Turn 2 — continue the same conversation:
 *   curl -sS -N -X POST "http://localhost:8088/invocations?agent_session_id=chat-001" \
 *     -H "Content-Type: application/json" \
 *     -d '{"message": "What hosted agent options does it offer?"}'
 */

using Azure.AI.AgentServer.Invocations;
using Azure.Identity;
using GitHub.Copilot;
using System.Text.Json;

// One-liner startup — wires up Kestrel on port 8088, OpenTelemetry, health probes,
// and the Invocations API endpoints. Telemetry is configured automatically:
// when APPLICATIONINSIGHTS_CONNECTION_STRING is set, traces and logs are sent to
// Application Insights with no extra code.
InvocationsServer.Run<HelloWorldHandler>(configure: builder =>
{
    if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING")))
        Console.Error.WriteLine(
            "[WARNING] APPLICATIONINSIGHTS_CONNECTION_STRING not set — traces will not be sent " +
            "to Application Insights. Set it to enable local telemetry. " +
            "(This variable is auto-injected in hosted Foundry containers — do not declare it in agent.manifest.yaml.)");

    var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
        ?? throw new InvalidOperationException(
            "FOUNDRY_PROJECT_ENDPOINT environment variable is not set.");

    var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")
        ?? throw new InvalidOperationException(
            "AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set.");

    var credential = new DefaultAzureCredential();
    var tokenScope = "https://ai.azure.com/.default";

    builder.Services.AddSingleton(new CopilotClient(new()
    {
        Mode = CopilotClientMode.Empty,
    }));

    builder.Services.AddSingleton(new ProviderConfig
    {
        Type = "azure",
        BaseUrl = $"{endpoint}/openai/v1",
        WireApi = "responses",
        // IMPROVEMENT: Use DefaultAzureCredential/GetBearerToken so it's simpler code and supports token refresh
        GetBearerToken = async _ => (await credential.GetTokenAsync(new([tokenScope]))).Token,
    });
});

/// <summary>
/// Hello World handler — forwards user input to a Foundry model via the Responses API,
/// streams the reply as SSE token events, and persists conversation history
/// in an in-memory session store keyed by <see cref="InvocationContext.SessionId"/>.
/// </summary>
/// <param name="responsesClient">Foundry Responses API client, injected via DI.</param>
/// <param name="logger">Logger injected via DI. Calls are automatically exported to Application Insights.</param>
public sealed class HelloWorldHandler(
    CopilotClient copilotClient,
    ProviderConfig providerConfig,
    ILogger<HelloWorldHandler> logger) : InvocationHandler
{
    // ── Required override ─────────────────────────────────────────────────────
    // HandleAsync is the only method you must override. It receives every
    // POST /invocations request.
    //
    // Three optional overrides exist for long-running operations (LRO):
    //   GetAsync          — handle GET /invocations/{id} status polls
    //   CancelAsync       — handle DELETE /invocations/{id} cancellation
    //   GetOpenApiAsync   — serve an OpenAPI spec at GET /invocations/docs/openapi.json
    // For a simple streaming agent like this one, none of them are needed.
    // ─────────────────────────────────────────────────────────────────────────
    public override async Task HandleAsync(
        HttpRequest request,
        HttpResponse response,
        InvocationContext context,
        CancellationToken cancellationToken)
    {
        // IMPROVEMENT: Stop requiring JSON input
        var userMessage = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken);

        // InvocationContext is provided by the Invocations SDK. It resolves
        // session and invocation identity from the incoming request headers
        // so you don't have to parse them yourself.
        logger.LogInformation(
            "Processing invocation {InvocationId} (session {SessionId})",
            context.InvocationId, context.SessionId);

        // IMPROVEMENT: Detect if session exists; don't try resuming one that doesn't
        // Retrieve or create session.
        var sessionMetadata = await copilotClient.GetSessionMetadataAsync(context.SessionId, cancellationToken);

        SessionConfigBase sessionConfig = sessionMetadata is null ? new SessionConfig { SessionId = context.SessionId } : new ResumeSessionConfig();
        sessionConfig.OnPermissionRequest = PermissionHandler.ApproveAll;
        sessionConfig.Streaming = true;
        sessionConfig.Model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME");
        sessionConfig.Provider = providerConfig;

        // IMPROVEMENT: Don't immediately resume and shut down the session on every request. Keep it alive for some period.
        await using var session = sessionMetadata is null
            ? await copilotClient.CreateSessionAsync((SessionConfig)sessionConfig, cancellationToken)
            : await copilotClient.ResumeSessionAsync(context.SessionId, (ResumeSessionConfig)sessionConfig, cancellationToken);

        response.ContentType = "text/event-stream";
        response.Headers.CacheControl = "no-cache";

        // SUGGESTION: The code below could be improved if the Foundry helper library contained methods for emitting /invocations chunks/responses instead of raw JSON
        using var subscription = session.On<AssistantMessageDeltaEvent>(async delta =>
        {
            var tokenEvent = JsonSerializer.Serialize(new { type = "token", content = delta.Data.DeltaContent });
            await response.WriteAsync($"data: {tokenEvent}\n\n", cancellationToken);
            await response.Body.FlushAsync(cancellationToken);
        });

        var reply = await session.SendAndWaitAsync(userMessage, cancellationToken: cancellationToken);

        // Send the final done event with the complete reply text.
        var doneEvent = JsonSerializer.Serialize(new
        {
            type = "done",
            invocation_id = context.InvocationId,
            session_id = context.SessionId,
            full_text = reply?.Data.Content ?? "",
        });
        await response.WriteAsync($"data: {doneEvent}\n\n", cancellationToken);
        await response.Body.FlushAsync(cancellationToken);
    }
}

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions