chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolboxAuthPathsClient</RootNamespace>
|
||||
<AssemblyName>hosted-toolbox-authpaths-client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted Toolbox Auth Paths — OAuth consent REPL client.
|
||||
//
|
||||
// This REPL drives the Hosted-Toolbox-AuthPaths agent and, unlike the plain
|
||||
// Using-Samples/SimpleAgent client, it understands the OAuth user-consent path.
|
||||
//
|
||||
// When a toolbox tool source is fronted by a per-user OAuth connection (for example a
|
||||
// delegated Microsoft Graph or a Logic Apps connector), the Foundry toolbox proxy cannot
|
||||
// call the tool until the end user has consented. The hosted agent surfaces that as an
|
||||
// oauth_consent_request output item carrying a consent link, and marks the response
|
||||
// incomplete. This is the platform-canonical consent surface (the same item the Foundry
|
||||
// Bot/Teams and A2A heads render as "open link + resume"), distinct from mcp_approval_request,
|
||||
// which is the generic approve/deny tool gate. This client:
|
||||
// 1. Detects the oauth_consent_request and extracts the consent link.
|
||||
// 2. PRINTS the consent link and waits for the user to press Enter once they have completed
|
||||
// the OAuth flow out of band. It never auto-opens a browser, so it works in headless,
|
||||
// SSH, container, and other non-GUI environments.
|
||||
// 3. RE-SENDS the original prompt on the same session. The proxy now holds the user's
|
||||
// delegated token, so the retried tool call succeeds.
|
||||
//
|
||||
// Important: an OAuth consent request is resumed by RE-SENDING the prompt, NOT by replying
|
||||
// with a ToolApprovalResponseContent. The consent request records no approval-id mapping on
|
||||
// the server, so a CreateResponse(...) reply would be rejected. Only function-tool approvals
|
||||
// (which this client also handles, for completeness) use the CreateResponse path.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Foundry project endpoint, or the local dev server base
|
||||
// (e.g. http://localhost:8088/api/projects/local).
|
||||
// AZURE_AI_AGENT_NAME - The registered server-side agent name
|
||||
// (default: hosted-toolbox-auth-paths-agent).
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
|
||||
?? "hosted-toolbox-auth-paths-agent";
|
||||
|
||||
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
|
||||
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
{
|
||||
// For local HTTP dev: present HTTPS to satisfy BearerTokenPolicy's TLS check, then swap
|
||||
// the scheme back to HTTP right before the request hits the wire.
|
||||
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Hosted Toolbox Auth Paths — OAuth consent client
|
||||
Connected to: {agentEndpoint}
|
||||
Ask something that needs the OAuth-protected tool to trigger consent.
|
||||
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
|
||||
{
|
||||
AgentResponse response = await agent.RunAsync(input, session);
|
||||
|
||||
// Resolve any consent or approval requests before showing the final answer. Each
|
||||
// round-trip either re-sends (OAuth consent) or replies with an approval decision
|
||||
// (function tools).
|
||||
while (true)
|
||||
{
|
||||
List<AIContent> contents = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.ToList();
|
||||
|
||||
// An OAuth consent request surfaces as an oauth_consent_request output item carrying a
|
||||
// consent link. It is resumed by RE-SENDING the prompt (no reply item), because the
|
||||
// proxy stores the user's delegated token server-side keyed to the user.
|
||||
List<string> consentUrls = contents
|
||||
.Select(TryExtractConsentUrl)
|
||||
.Where(url => url is not null)
|
||||
.Select(url => url!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (consentUrls.Count > 0)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("The agent needs your OAuth consent before it can call a tool on your behalf.");
|
||||
Console.WriteLine("Open the link(s) below in any browser and complete the sign-in / consent:");
|
||||
foreach (string url in consentUrls)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(url);
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.ResetColor();
|
||||
|
||||
// Wait for the user to finish consent out of band. They can press Enter once done.
|
||||
// The value is not needed to resume — the proxy stores the delegated token
|
||||
// server-side keyed to the user.
|
||||
Console.Write("After completing consent, press Enter to continue... ");
|
||||
_ = Console.ReadLine();
|
||||
|
||||
// Re-send the SAME prompt on the SAME session. The proxy now holds the user's
|
||||
// delegated token, so the retried tool call succeeds. Do NOT CreateResponse here.
|
||||
response = await agent.RunAsync(input, session);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function-tool approval path (Y/N), included so the client also handles agents
|
||||
// that mix human-in-the-loop function approvals with OAuth consent.
|
||||
List<ToolApprovalRequestContent> approvals = contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
|
||||
if (approvals.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
List<ChatMessage> decisions = approvals.ConvertAll(approval =>
|
||||
{
|
||||
string name = (approval.ToolCall as FunctionCallContent)?.Name ?? "tool";
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write($"Approve tool call '{name}'? [Y/N] ");
|
||||
Console.ResetColor();
|
||||
bool approved = Console.ReadLine()?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
return new ChatMessage(ChatRole.User, [approval.CreateResponse(approved)]);
|
||||
});
|
||||
|
||||
response = await agent.RunAsync(decisions, session);
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"Agent> {response}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Extracts an OAuth consent link from a response content, or returns null when the content is
|
||||
// not a consent request. The canonical surface is an `oauth_consent_request` output item, which
|
||||
// the high-level client exposes through AIContent.RawRepresentation (its `ConsentLink` field
|
||||
// carries the URL). For resilience this also handles a consent URL surfaced inside a
|
||||
// ToolApprovalRequestContent payload (the older mcp_approval_request shape). Plain
|
||||
// human-in-the-loop function approvals carry no URL and return null.
|
||||
static string? TryExtractConsentUrl(AIContent content)
|
||||
{
|
||||
// 1) Canonical: oauth_consent_request output item via RawRepresentation.
|
||||
if (TryGetConsentLinkFromRaw(content.RawRepresentation) is { } fromRaw)
|
||||
{
|
||||
return fromRaw;
|
||||
}
|
||||
|
||||
// 2) Back-compat: a consent URL carried in an approval request's arguments.
|
||||
if (content is ToolApprovalRequestContent approval)
|
||||
{
|
||||
return TryExtractConsentUrlFromApproval(approval);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reads a consent link from a raw oauth_consent_request item. The high-level client surfaces this
|
||||
// item as a base AIContent whose RawRepresentation is an SDK response item: in the typed case it
|
||||
// exposes a `ConsentLink` member, but the OpenAI Responses client parses the (non-OpenAI)
|
||||
// oauth_consent_request as an *unknown* item, so the link only lives in the item's JSON. We try the
|
||||
// typed member first, then fall back to serializing the persistable model and reading `consent_link`.
|
||||
static string? TryGetConsentLinkFromRaw(object? raw)
|
||||
{
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fast path: a typed consent item exposes ConsentLink directly (Uri or string).
|
||||
switch (raw.GetType().GetProperty("ConsentLink")?.GetValue(raw))
|
||||
{
|
||||
case Uri uri when LooksLikeUrl(uri.AbsoluteUri):
|
||||
return uri.AbsoluteUri;
|
||||
case string s when LooksLikeUrl(s):
|
||||
return s;
|
||||
}
|
||||
|
||||
// General path: serialize the unknown response item back to JSON and read the wire fields.
|
||||
try
|
||||
{
|
||||
BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J"));
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty("type", out JsonElement typeProp)
|
||||
&& typeProp.GetString() == "oauth_consent_request"
|
||||
&& root.TryGetProperty("consent_link", out JsonElement linkProp)
|
||||
&& linkProp.GetString() is string link
|
||||
&& LooksLikeUrl(link))
|
||||
{
|
||||
return link;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not a persistable model, or no consent_link present — fall through.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scans an approval request's tool-call arguments for a consent URL (older wire shape, where a
|
||||
// toolbox OAuth consent was surfaced as an mcp_approval_request carrying a consent_url argument).
|
||||
static string? TryExtractConsentUrlFromApproval(ToolApprovalRequestContent approval)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = approval.ToolCall switch
|
||||
{
|
||||
McpServerToolCallContent mcpCall => mcpCall.Arguments,
|
||||
FunctionCallContent functionCall => functionCall.Arguments,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Only the explicit consent_url key counts (the legacy mcp_approval_request consent shape).
|
||||
// We deliberately do NOT scan arbitrary argument values for URLs, so a normal function-tool
|
||||
// approval that happens to carry a URL argument is never misread as an OAuth consent request.
|
||||
if (arguments is null || !arguments.TryGetValue("consent_url", out object? value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
string s when LooksLikeUrl(s) => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } element
|
||||
when element.GetString() is string elementString && LooksLikeUrl(elementString) => elementString,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static bool LooksLikeUrl(string value) =>
|
||||
value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// For Local Development Only.
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient to target a
|
||||
/// local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
|
||||
/// </summary>
|
||||
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void RewriteScheme(PipelineMessage message)
|
||||
{
|
||||
var uri = message.Request.Uri!;
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Hosted Toolbox Auth Paths — OAuth consent client
|
||||
|
||||
A REPL client for the [`Hosted-Toolbox-AuthPaths/`](../../Hosted-Toolbox-AuthPaths/) agent that understands the **OAuth user-consent** path.
|
||||
|
||||
The plain [`SimpleAgent/`](../SimpleAgent/) REPL is enough for the key-based, agent-identity, and inline-`Authorization` tools. It is **not** enough for a tool fronted by a **per-user OAuth connection** (for example a delegated Microsoft Graph connector or a Logic Apps connector), because that tool cannot run until the end user has consented. This client handles that flow.
|
||||
|
||||
## What it does
|
||||
|
||||
When a toolbox tool source needs the user's delegated token, the hosted agent surfaces an `oauth_consent_request` output item that carries a **consent link** and marks the response `incomplete`. This client:
|
||||
|
||||
1. Detects the `oauth_consent_request` and extracts the consent link.
|
||||
2. **Prints the consent link** so you can open it in any browser and complete the OAuth flow out of band. It never auto-opens a browser, so it works in headless, SSH, and container shells.
|
||||
3. Waits for you to press Enter, then **re-sends the original prompt on the same session**. The toolbox proxy now holds your delegated token, so the retried tool call succeeds.
|
||||
|
||||
> **Why re-send instead of replying with an approval?** An OAuth consent request records no approval-id mapping on the server, so a `ToolApprovalResponseContent` (`CreateResponse(...)`) reply would be rejected. The user's token lives on the proxy after consent, so simply re-sending the same prompt resumes the call. Function-tool approvals are different: those *do* use `CreateResponse(...)`, and this client handles them too for completeness.
|
||||
|
||||
## How the consent flows (no token ever touches this client)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor You
|
||||
participant Client
|
||||
participant Agent as Hosted Agent
|
||||
participant Proxy as Toolbox Proxy
|
||||
participant IdP as Identity Provider
|
||||
participant Target as Target Service
|
||||
|
||||
You->>Client: prompt
|
||||
Client->>Agent: run prompt
|
||||
Agent->>Proxy: call tool
|
||||
Proxy-->>Agent: CONSENT_REQUIRED (no token yet)
|
||||
Agent-->>Client: oauth_consent_request (consent link) + incomplete
|
||||
Client-->>You: print consent link
|
||||
You->>IdP: consent in browser
|
||||
IdP->>Proxy: token (stored, bound to you)
|
||||
You->>Client: press Enter
|
||||
Client->>Agent: re-send same prompt
|
||||
Agent->>Proxy: call tool
|
||||
Proxy->>Target: OBO token
|
||||
Target-->>Proxy: result
|
||||
Proxy-->>Agent: result
|
||||
Agent-->>Client: result
|
||||
Client-->>You: result
|
||||
```
|
||||
|
||||
The client never sees the user's token. Consent and the on-behalf-of token exchange happen entirely between the user, the identity provider, and the toolbox proxy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The [`Hosted-Toolbox-AuthPaths/`](../../Hosted-Toolbox-AuthPaths/) agent running (locally or deployed) with at least one toolbox tool source configured for a per-user OAuth connection. See that sample's README, **Auth path #4 (OAuth user consent)**.
|
||||
- `az login` so the client can mint a bearer token to reach the agent endpoint.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
cd Hosted-Toolbox-AuthPaths-Client
|
||||
|
||||
# Against the local dev server (the {project} segment is a wildcard the server ignores):
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
|
||||
# Or against a deployed agent:
|
||||
# $env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
# $env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
Then ask something that needs the OAuth-protected tool. When the consent prompt appears, the client prints the consent link. Open it in any browser, complete sign-in, then press Enter and the client re-sends automatically.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | yes | — | Foundry project endpoint, or the local dev server base. `FOUNDRY_PROJECT_ENDPOINT` is read as a fallback. |
|
||||
| `AZURE_AI_AGENT_NAME` | no | `hosted-toolbox-auth-paths-agent` | Registered server-side agent name. |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Using-Samples — client REPLs for the hosted agents
|
||||
|
||||
This folder holds small **client** console apps that connect to the **server** samples in the
|
||||
sibling `Hosted-*` folders. Each `Hosted-*` project is an agent you host (locally with
|
||||
`dotnet run` or deployed to Foundry); the projects here are the thing that *talks* to them.
|
||||
|
||||
## Why these exist
|
||||
|
||||
A hosted Foundry agent is an HTTP server, not a chat UI. It exposes only the per-agent OpenAI
|
||||
endpoint shape that the platform routes to:
|
||||
|
||||
```
|
||||
{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai
|
||||
```
|
||||
|
||||
There is no built-in console to poke it with. To actually exercise an agent — send a prompt,
|
||||
watch it call its tools, read the streamed answer — you need a client that builds a
|
||||
`FoundryAgent` against that endpoint and drives a conversation. That is all these REPLs do:
|
||||
|
||||
1. Read `FOUNDRY_PROJECT_ENDPOINT` + `AZURE_AI_AGENT_NAME` from the environment.
|
||||
2. Derive the per-agent OpenAI endpoint URL.
|
||||
3. `AIProjectClient(...).AsAIAgent(agentEndpoint)` → `FoundryAgent`.
|
||||
4. Loop: read a line, `RunStreamingAsync`, print the streamed reply.
|
||||
|
||||
The client is deliberately dumb. It knows nothing about tools, files, toolboxes, or auth — all
|
||||
of that is the hosted agent's concern on the server side. Swapping which agent you chat with is
|
||||
just a matter of changing `AZURE_AI_AGENT_NAME`.
|
||||
|
||||
## Local HTTP dev
|
||||
|
||||
When the target is a local `http://localhost:8088` dev server, the REPLs install a small
|
||||
`HttpSchemeRewritePolicy`: `AIProjectClient`/`BearerTokenPolicy` require HTTPS, so the client
|
||||
presents the endpoint as `https://` to satisfy the TLS check, then rewrites the scheme back to
|
||||
`http://` right before the request hits the wire. This is local-development only.
|
||||
|
||||
## The clients
|
||||
|
||||
| Client | What it targets | Notes |
|
||||
|---|---|---|
|
||||
| [`SimpleAgent/`](./SimpleAgent/) | Any hosted agent | Generic, agent-agnostic REPL. Point it at any `Hosted-*` server via `AZURE_AI_AGENT_NAME`. Used by `Hosted-Toolbox`, `Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools`. |
|
||||
| [`SessionFilesClient/`](./SessionFilesClient/) | [`Hosted-Files`](../Hosted-Files/) | Same shape as `SimpleAgent`, framed around the bundled-files demo. |
|
||||
|
||||
## Configuration (common to all clients)
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
|
||||
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
|
||||
```
|
||||
|
||||
Both are required. Authenticate with `az login` before running. See each client's own README for
|
||||
its end-to-end walkthrough.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
|
||||
// https://<host>/api/projects/<project>
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
|
||||
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
|
||||
|
||||
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
|
||||
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
|
||||
|
||||
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
{
|
||||
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
|
||||
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
|
||||
// before the request hits the wire.
|
||||
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// ── REPL ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Session Files Client
|
||||
Connected to: {agentEndpoint}
|
||||
Try: "Give me the total revenue in the contoso file."
|
||||
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, session))
|
||||
{
|
||||
Console.Write(update);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
|
||||
/// <summary>
|
||||
/// For Local Development Only
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
|
||||
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
|
||||
/// </summary>
|
||||
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void RewriteScheme(PipelineMessage message)
|
||||
{
|
||||
var uri = message.Request.Uri!;
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# SessionFilesClient
|
||||
|
||||
A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run.
|
||||
|
||||
The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
|
||||
AZURE_AI_AGENT_NAME=hosted-files
|
||||
```
|
||||
|
||||
Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and `AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent OpenAI endpoint URL from these.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-files"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## End-to-end demo
|
||||
|
||||
With the [`Hosted-Files`](../../Hosted-Files/) agent running:
|
||||
|
||||
```text
|
||||
══════════════════════════════════════════════════════════
|
||||
Session Files Client
|
||||
Connected to: http://localhost:8088/
|
||||
Try: "Give me the total revenue in the contoso file."
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
|
||||
You> Give me the total revenue in the contoso file.
|
||||
Agent> The contoso file reports total revenue of "$1,482.6M".
|
||||
|
||||
You> quit
|
||||
Goodbye!
|
||||
```
|
||||
|
||||
The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>SessionFilesClient</RootNamespace>
|
||||
<AssemblyName>session-files-client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
|
||||
// https://<host>/api/projects/<project>
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
|
||||
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
|
||||
|
||||
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
|
||||
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
|
||||
|
||||
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
{
|
||||
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
|
||||
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
|
||||
// before the request hits the wire.
|
||||
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// ── REPL ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple 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, session))
|
||||
{
|
||||
Console.Write(update);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
|
||||
/// <summary>
|
||||
/// For Local Development Only
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
|
||||
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
|
||||
/// </summary>
|
||||
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void RewriteScheme(PipelineMessage message)
|
||||
{
|
||||
var uri = message.Request.Uri!;
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# SimpleAgent
|
||||
|
||||
A generic, agent-agnostic chat REPL for any hosted Foundry agent. Point it at a running
|
||||
`Hosted-*` agent via `AZURE_AI_AGENT_NAME`, and it builds a `FoundryAgent` against that agent's
|
||||
per-agent OpenAI endpoint and streams replies. This is the shared client that `Hosted-Toolbox`,
|
||||
`Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools` reference for their end-to-end demos.
|
||||
|
||||
It knows nothing about the agent's tools, toolboxes, files, or auth — those are entirely the
|
||||
server's concern. Changing which agent you chat with is just a different `AZURE_AI_AGENT_NAME`.
|
||||
See [`../README.md`](../README.md) for why these client REPLs exist at all.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A running hosted agent (any `Hosted-*` sample, locally via `dotnet run` or deployed to Foundry)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
|
||||
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
|
||||
```
|
||||
|
||||
Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and
|
||||
`AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent
|
||||
OpenAI endpoint URL (`{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`)
|
||||
from these.
|
||||
|
||||
## Run
|
||||
|
||||
Against a local Hosted-Toolbox agent listening on `http://localhost:8088`:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-agent"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
When the project endpoint is `http://`, the client presents it as `https://` to satisfy the
|
||||
bearer-token TLS check, then rewrites the scheme back to `http://` right before transport
|
||||
(local-development only).
|
||||
|
||||
## End-to-end demo
|
||||
|
||||
With a hosted agent running:
|
||||
|
||||
```text
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple Agent Sample
|
||||
Connected to: https://localhost:8088/api/projects/local/agents/hosted-toolbox-agent/endpoint/protocols/openai
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
|
||||
You> What tools do you have available, and what can they do?
|
||||
Agent> I have the following tools from the toolbox: ...
|
||||
|
||||
You> quit
|
||||
Goodbye!
|
||||
```
|
||||
|
||||
The client only sent a chat prompt; the agent resolved its toolbox tools server-side and answered.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>SimpleAgentClient</RootNamespace>
|
||||
<AssemblyName>simple-agent-client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user