mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a653af73e | ||
|
|
d1bc78108d | ||
|
|
c2f9544763 | ||
|
|
6312001ecb | ||
|
|
6a23dcd555 | ||
|
|
1d1e58e12a | ||
|
|
5e5c6976f1 | ||
|
|
94ce49dcf5 | ||
|
|
9007b3262a | ||
|
|
f5f0d828ab | ||
|
|
13b8e68503 | ||
|
|
07ea764469 | ||
|
|
4a83d92c96 | ||
|
|
0219e17be2 |
@@ -32,13 +32,7 @@ runs:
|
||||
if grep -q "name = \"$pkg\"" "$f"; then
|
||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
|
||||
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
|
||||
fi
|
||||
else
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
fi
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||
fi
|
||||
done
|
||||
@@ -46,4 +40,4 @@ runs:
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
|
||||
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
|
||||
|
||||
@@ -149,14 +149,16 @@ jobs:
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: issue triage preflight did not allow automation."
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-05-07
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Hosted session identity context for Foundry Hosting
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
|
||||
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
|
||||
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
|
||||
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
|
||||
- Local Docker debugging must remain possible when the platform headers are absent.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
|
||||
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
|
||||
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
|
||||
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
|
||||
|
||||
For the source of identity:
|
||||
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
|
||||
- B. The OpenAI Responses spec's top-level `request.User` field.
|
||||
- C. A custom HTTP header `x-client-user`.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
|
||||
|
||||
Rationale:
|
||||
|
||||
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
|
||||
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
|
||||
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
|
||||
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
|
||||
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
|
||||
|
||||
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||
|
||||
| Type | Visibility | Purpose |
|
||||
|---|---|---|
|
||||
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
|
||||
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
|
||||
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
|
||||
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
|
||||
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
|
||||
|
||||
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
|
||||
|
||||
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
|
||||
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
|
||||
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
|
||||
- **No session (`session is null`):** nothing to stamp; skip.
|
||||
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
|
||||
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
|
||||
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
|
||||
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
|
||||
|
||||
Negative:
|
||||
|
||||
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
|
||||
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
|
||||
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
|
||||
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
|
||||
@@ -26,10 +26,10 @@
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.55.0" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.53.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
@@ -112,7 +112,6 @@
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
|
||||
@@ -122,9 +122,8 @@
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
</Folder>
|
||||
@@ -328,15 +327,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -24,11 +24,6 @@ public static class AnsiEscapes
|
||||
/// </summary>
|
||||
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the current line from the cursor position to the end of the line (EL 0).
|
||||
/// </summary>
|
||||
public static string EraseToEndOfLine => "\x1b[0K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire current line (EL 2).
|
||||
/// </summary>
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
{
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(line);
|
||||
row++;
|
||||
@@ -51,7 +51,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
|
||||
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
|
||||
@@ -58,11 +58,11 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
|
||||
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = props.Width - promptLength;
|
||||
int textWidth = this.Width - promptLength;
|
||||
string indent = new(' ', promptLength);
|
||||
|
||||
// First line: prompt + start of text
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(props.Prompt);
|
||||
|
||||
@@ -90,7 +90,7 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
|
||||
while (offset < props.Text.Length)
|
||||
{
|
||||
int chunk = Math.Min(textWidth, props.Text.Length - offset);
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(indent);
|
||||
Console.Write(props.Text[offset..(offset + chunk)]);
|
||||
|
||||
@@ -17,7 +17,7 @@ public record TextPanelProps : ConsoleReactiveProps
|
||||
/// <summary>
|
||||
/// A component that renders a list of pre-rendered string items vertically.
|
||||
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
|
||||
/// exceeds the number of output lines, leftover lines are erased.
|
||||
/// </summary>
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
@@ -51,18 +51,18 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
|
||||
for (int j = 0; j < lineCount; j++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
currentRow++;
|
||||
}
|
||||
}
|
||||
|
||||
// If the component height exceeds the output, erase leftover lines
|
||||
if (props.Height > currentRow)
|
||||
if (this.Height > currentRow)
|
||||
{
|
||||
for (int i = currentRow; i < props.Height; i++)
|
||||
for (int i = currentRow; i < this.Height; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
}
|
||||
|
||||
// Move cursor to the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
|
||||
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
|
||||
/// </summary>
|
||||
public record TopBottomRuleProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the width of the horizontal rules in characters.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
|
||||
public ConsoleColor? Color { get; init; }
|
||||
}
|
||||
@@ -29,7 +32,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
|
||||
int childrenHeight = 0;
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
childrenHeight += child.BaseProps?.Height ?? 0;
|
||||
childrenHeight += child.Height;
|
||||
}
|
||||
|
||||
// Top rule + children + bottom rule
|
||||
@@ -48,11 +51,11 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
|
||||
}
|
||||
|
||||
// Top rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
|
||||
Console.Write(rule);
|
||||
|
||||
// Render children stacked below the top rule
|
||||
int currentY = props.Y + 1;
|
||||
int currentY = this.Y + 1;
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
@@ -61,9 +64,10 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
|
||||
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
|
||||
child.X = this.X;
|
||||
child.Y = currentY;
|
||||
child.Render();
|
||||
currentY += child.BaseProps.Height;
|
||||
currentY += child.Height;
|
||||
}
|
||||
|
||||
if (props.Color.HasValue)
|
||||
@@ -72,7 +76,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
|
||||
}
|
||||
|
||||
// Bottom rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
|
||||
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
|
||||
Console.Write(rule);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
|
||||
+17
-47
@@ -3,8 +3,8 @@
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for all console UI components. Provides access to layout
|
||||
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
|
||||
/// Abstract base class for all console UI components. Provides layout properties
|
||||
/// (position and size) and a <see cref="Render"/> method for drawing to the console.
|
||||
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
|
||||
/// </summary>
|
||||
public abstract class ConsoleReactiveComponent
|
||||
@@ -13,21 +13,20 @@ public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||
/// knowing the concrete props type.
|
||||
/// </summary>
|
||||
public abstract ConsoleReactiveProps? BaseProps { get; set; }
|
||||
/// <summary>Gets or sets the 1-based column position of the component.</summary>
|
||||
public int X { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the 1-based row position of the component.</summary>
|
||||
public int Y { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the width of the component in columns.</summary>
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the height of the component in rows.</summary>
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Renders the component to the console at its current position.</summary>
|
||||
public abstract void Render();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
|
||||
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
|
||||
/// </summary>
|
||||
public abstract void Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,13 +46,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// <summary>Gets or sets the component's props (external configuration).</summary>
|
||||
public TProps? Props { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ConsoleReactiveProps? BaseProps
|
||||
{
|
||||
get => this.Props;
|
||||
set => this.Props = (TProps?)value;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the component's internal state.</summary>
|
||||
protected TState? State { get; set; }
|
||||
|
||||
@@ -81,8 +73,8 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
return;
|
||||
}
|
||||
|
||||
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
|
||||
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
|
||||
if (ReferenceEquals(this.Props, this._lastRenderedProps)
|
||||
&& ReferenceEquals(this.State, this._lastRenderedState))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -94,16 +86,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Invalidate()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
{
|
||||
this._lastRenderedProps = default;
|
||||
this._lastRenderedState = default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
|
||||
/// </summary>
|
||||
@@ -113,23 +95,11 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base record for component props. Provides layout properties (position and size)
|
||||
/// and an optional <see cref="Children"/> collection for composing child components.
|
||||
/// Base record for component props. Provides an optional <see cref="Children"/> collection
|
||||
/// for composing child components.
|
||||
/// </summary>
|
||||
public record ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the 1-based column position of the component.</summary>
|
||||
public int X { get; init; }
|
||||
|
||||
/// <summary>Gets the 1-based row position of the component.</summary>
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Gets the width of the component in columns.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Gets the height of the component in rows.</summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>Gets the child components to render within this component.</summary>
|
||||
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
|
||||
}
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>/session-export <filename></c> and <c>/session-import <filename></c>
|
||||
/// commands for serializing the current session to a file and restoring a session from a file.
|
||||
/// </summary>
|
||||
public sealed class SessionCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SessionCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent used for session serialization and deserialization.</param>
|
||||
public SessionCommandHandler(AIAgent agent)
|
||||
{
|
||||
this._agent = agent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => "/session-export <file> | /session-import <file>";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
string command = input.Split(' ', 2)[0];
|
||||
|
||||
if (command.Equals("/session-export", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await this.HandleExportAsync(input, session, ux).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.Equals("/session-import", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await this.HandleImportAsync(input, ux).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task HandleExportAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("Usage: /session-export <filename>").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string filename = parts[1];
|
||||
try
|
||||
{
|
||||
JsonElement serialized = await this._agent.SerializeSessionAsync(session).ConfigureAwait(false);
|
||||
string json = JsonSerializer.Serialize(serialized);
|
||||
await File.WriteAllTextAsync(filename, json).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync($"Session exported to {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"Failed to export session to {filename}: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleImportAsync(string input, IUXStateDriver ux)
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("Usage: /session-import <filename>").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string filename = parts[1];
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(filename).ConfigureAwait(false);
|
||||
JsonElement element = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
AgentSession newSession = await this._agent.DeserializeSessionAsync(element).ConfigureAwait(false);
|
||||
await ux.ReplaceSessionAsync(newSession).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync($"Session imported from {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"File not found: {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"Failed to import session from {filename}: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps,
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
|
||||
|
||||
bool hasMode = props.Mode is not null;
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
|
||||
];
|
||||
|
||||
private readonly Timer _timer;
|
||||
private AgentStatusProps? _previousProps;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
|
||||
@@ -86,12 +85,7 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
if (props != this._previousProps)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
this._previousProps = props;
|
||||
}
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
|
||||
|
||||
if (props.ShowSpinner)
|
||||
{
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using OpenTelemetry;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// A simple OpenTelemetry span exporter that writes completed activities (spans) to a text file.
|
||||
/// Each span is formatted as a human-readable block with timestamps, operation name, duration,
|
||||
/// status, and any tags/events.
|
||||
/// </summary>
|
||||
public sealed class FileSpanExporter : BaseExporter<Activity>
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public FileSpanExporter(string filePath)
|
||||
{
|
||||
this._filePath = filePath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
}
|
||||
|
||||
public override ExportResult Export(in Batch<Activity> batch)
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
using var writer = new StreamWriter(this._filePath, append: true);
|
||||
foreach (var activity in batch)
|
||||
{
|
||||
WriteActivity(writer, activity);
|
||||
}
|
||||
}
|
||||
|
||||
return ExportResult.Success;
|
||||
}
|
||||
|
||||
private static void WriteActivity(StreamWriter writer, Activity activity)
|
||||
{
|
||||
var start = activity.StartTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
var duration = activity.Duration.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
|
||||
|
||||
writer.WriteLine($"[{start}] {activity.OperationName} ({duration}ms) [{activity.Status}]");
|
||||
|
||||
if (!string.IsNullOrEmpty(activity.DisplayName) && activity.DisplayName != activity.OperationName)
|
||||
{
|
||||
writer.WriteLine($" DisplayName: {activity.DisplayName}");
|
||||
}
|
||||
|
||||
foreach (var tag in activity.Tags)
|
||||
{
|
||||
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||
}
|
||||
|
||||
foreach (var ev in activity.Events)
|
||||
{
|
||||
writer.WriteLine($" Event: {ev.Name} @ {ev.Timestamp:HH:mm:ss.fff}");
|
||||
foreach (var tag in ev.Tags)
|
||||
{
|
||||
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,14 @@ namespace Harness.Shared.Console;
|
||||
public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentSession _session;
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly MessageInjectingChatClient? _messageInjector;
|
||||
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
|
||||
private readonly IReadOnlyList<ConsoleObserver> _observers;
|
||||
private readonly IUXStateDriver _ux;
|
||||
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||
|
||||
private AgentSession _session;
|
||||
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
|
||||
@@ -62,25 +62,6 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
/// </summary>
|
||||
public string HelpText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current session with the specified session. Used by the UX driver
|
||||
/// when importing a serialized session. Acquires the input gate to ensure no
|
||||
/// concurrent agent turn is reading the session.
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
internal async Task ReplaceSessionAsync(AgentSession newSession)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this._session = newSession;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => this._inputGate.Dispose();
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
getState: () => this.State!,
|
||||
setState: s => this.SetState(s),
|
||||
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
|
||||
replaceSession: s => this.Runner!.ReplaceSessionAsync(s),
|
||||
modeColors: modeColors);
|
||||
|
||||
this.Runner = runnerFactory(this._uxDriver);
|
||||
@@ -371,7 +370,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
listProps = listProps with { Height = bottomChildHeight };
|
||||
this._listSelection.Height = bottomChildHeight;
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
@@ -398,7 +397,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
}
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||
this._textInput.Width = state.ConsoleWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
@@ -412,7 +412,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||
this._textInput.Width = state.ConsoleWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
@@ -457,16 +458,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
|
||||
// Invalidate all children so they re-render even if props haven't changed
|
||||
this._rule.Invalidate();
|
||||
this._textScrollPanel.Invalidate();
|
||||
this._textPanel.Invalidate();
|
||||
this._queuedPanel.Invalidate();
|
||||
this._agentStatus.Invalidate();
|
||||
this._modeAndHelp.Invalidate();
|
||||
this._textInput.Invalidate();
|
||||
this._listSelection.Invalidate();
|
||||
}
|
||||
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
@@ -478,35 +469,35 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
this._textScrollPanel.Y = 1;
|
||||
this._textScrollPanel.Width = state.ConsoleWidth;
|
||||
this._textScrollPanel.Height = scrollBottom;
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = scrollBottom,
|
||||
Items = scrollItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.X = 1;
|
||||
this._textPanel.Y = scrollBottom + 1;
|
||||
this._textPanel.Width = state.ConsoleWidth;
|
||||
this._textPanel.Height = textPanelHeight;
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = scrollBottom + 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = textPanelHeight,
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render queued input items between text panel and agent status
|
||||
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||
this._queuedPanel.X = 1;
|
||||
this._queuedPanel.Y = queuedPanelY;
|
||||
this._queuedPanel.Width = state.ConsoleWidth;
|
||||
this._queuedPanel.Height = queuedPanelHeight;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = queuedPanelY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = queuedPanelHeight,
|
||||
Items = state.QueuedItems,
|
||||
};
|
||||
this._queuedPanel.Render();
|
||||
@@ -515,41 +506,32 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
int agentStatusY = queuedPanelY + queuedPanelHeight;
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
this._agentStatus.Props = agentStatusProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = agentStatusY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = agentStatusHeight,
|
||||
};
|
||||
this._agentStatus.X = 1;
|
||||
this._agentStatus.Y = agentStatusY;
|
||||
this._agentStatus.Width = state.ConsoleWidth;
|
||||
this._agentStatus.Height = agentStatusHeight;
|
||||
this._agentStatus.Props = agentStatusProps;
|
||||
this._agentStatus.Render();
|
||||
}
|
||||
|
||||
// Render the bottom rule + child below the agent status
|
||||
this._rule.Props = ruleProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = agentStatusY + agentStatusHeight,
|
||||
};
|
||||
this._rule.X = 1;
|
||||
this._rule.Y = agentStatusY + agentStatusHeight;
|
||||
this._rule.Props = ruleProps;
|
||||
this._rule.Render();
|
||||
|
||||
// Render the mode-and-help line below the bottom rule
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = modeAndHelpY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = modeAndHelpHeight,
|
||||
};
|
||||
int modeAndHelpY = this._rule.Y + ruleHeight;
|
||||
this._modeAndHelp.X = 1;
|
||||
this._modeAndHelp.Y = modeAndHelpY;
|
||||
this._modeAndHelp.Width = state.ConsoleWidth;
|
||||
this._modeAndHelp.Height = modeAndHelpHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps;
|
||||
this._modeAndHelp.Render();
|
||||
}
|
||||
|
||||
// Clear the bottom padding line
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
this.PositionCursor(state);
|
||||
}
|
||||
@@ -563,7 +545,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
int textWidth = state.ConsoleWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
|
||||
int textInputY = this._rule.Y + 1;
|
||||
|
||||
if (textWidth <= 0 || textLength == 0)
|
||||
{
|
||||
@@ -581,7 +563,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
|
||||
{
|
||||
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
@@ -25,8 +24,6 @@ public static class HarnessConsole
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
System.Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
|
||||
var observers = options.Observers
|
||||
?? HarnessConsoleOptions.BuildDefaultObservers();
|
||||
@@ -36,9 +33,7 @@ public static class HarnessConsole
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||
|
||||
AgentSession session = options.SessionFactory is not null
|
||||
? await options.SessionFactory(agent)
|
||||
: await agent.CreateSessionAsync();
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
using var component = new HarnessAppComponent(
|
||||
placeholder: userPrompt,
|
||||
@@ -68,7 +63,6 @@ public static class HarnessConsole
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
|
||||
System.Console.WriteLine("Goodbye!");
|
||||
|
||||
@@ -45,12 +45,6 @@ public class HarnessConsoleOptions
|
||||
/// </summary>
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
|
||||
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
|
||||
/// </summary>
|
||||
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers without planning support.
|
||||
/// Includes tool call display, tool approval, error display, reasoning display,
|
||||
@@ -134,7 +128,6 @@ public class HarnessConsoleOptions
|
||||
new ExitCommandHandler(),
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
|
||||
new SessionCommandHandler(agent),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
@@ -17,7 +16,6 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
private readonly Func<HarnessAppComponentState> _getState;
|
||||
private readonly Action<HarnessAppComponentState> _setState;
|
||||
private readonly Action _requestShutdown;
|
||||
private readonly Func<AgentSession, Task> _replaceSession;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<string> _outputItems = [];
|
||||
private readonly object _stateLock = new();
|
||||
@@ -34,19 +32,16 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
/// <param name="getState">Returns the component's current state.</param>
|
||||
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
||||
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
||||
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessConsoleUXStateDriver(
|
||||
Func<HarnessAppComponentState> getState,
|
||||
Action<HarnessAppComponentState> setState,
|
||||
Action requestShutdown,
|
||||
Func<AgentSession, Task> replaceSession,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._getState = getState;
|
||||
this._setState = setState;
|
||||
this._requestShutdown = requestShutdown;
|
||||
this._replaceSession = replaceSession;
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = getState().ModeText;
|
||||
}
|
||||
@@ -410,7 +405,4 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestShutdown() => this._requestShutdown();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable VSTHRD002 // Synchronous waits are required by OpenTelemetry enrichment callbacks.
|
||||
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Provides factory methods for creating pre-configured OpenTelemetry tracing for harness samples.
|
||||
/// </summary>
|
||||
public static class HarnessTracing
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="TracerProvider"/> that captures spans from the specified source and HTTP client activity,
|
||||
/// enriching HTTP spans with full request/response headers and bodies, and exports all spans to a timestamped
|
||||
/// text file in the application base directory.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">The activity source name to subscribe to (e.g., "Harness.Research").</param>
|
||||
/// <returns>A configured <see cref="TracerProvider"/>, or <see langword="null"/> if the builder returns null.</returns>
|
||||
public static TracerProvider? CreateFileTracerProvider(string sourceName)
|
||||
{
|
||||
var traceLogPath = Path.Combine(AppContext.BaseDirectory, $"traces_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid()}.log");
|
||||
|
||||
return Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddHttpClientInstrumentation((options) =>
|
||||
{
|
||||
options.EnrichWithHttpRequestMessage = (activity, request) =>
|
||||
{
|
||||
activity.SetTag("http.request.headers", request.Headers.ToString());
|
||||
if (request.Content != null)
|
||||
{
|
||||
activity.SetTag("http.request.content.headers", request.Content.Headers.ToString());
|
||||
var content = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
activity.SetTag("http.request.content.body", content);
|
||||
}
|
||||
};
|
||||
|
||||
options.EnrichWithHttpResponseMessage = (activity, response) =>
|
||||
{
|
||||
activity.SetTag("http.response.headers", response.Headers.ToString());
|
||||
if (response.Content != null)
|
||||
{
|
||||
activity.SetTag("http.response.content.headers", response.Content.Headers.ToString());
|
||||
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
activity.SetTag("http.response.content.body", content);
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddProcessor(new SimpleActivityExportProcessor(new FileSpanExporter(traceLogPath)))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,6 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
@@ -118,11 +117,4 @@ public interface IUXStateDriver
|
||||
/// on the owning component.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current agent session with the specified session (e.g., after importing
|
||||
/// a serialized session from a file).
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
Task ReplaceSessionAsync(AgentSession newSession);
|
||||
}
|
||||
|
||||
-4
@@ -31,10 +31,6 @@ public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is WebSearchToolCallContent)
|
||||
{
|
||||
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
|
||||
+9
-9
@@ -6,26 +6,26 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
|
||||
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
|
||||
/// for task start, continue, wait, and result retrieval operations.
|
||||
/// </summary>
|
||||
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
|
||||
public sealed class SubAgentToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
|
||||
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStartBackgroundTask(FunctionCallContent call)
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetStringArgumentValue(call, "agentName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
+1
-45
@@ -19,7 +19,7 @@ public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatCompleteTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
@@ -64,50 +64,6 @@ public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatCompleteTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entries = new List<(int Id, string? Reason)>();
|
||||
|
||||
if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? reason = item.TryGetProperty("reason", out JsonElement reasonElement)
|
||||
? reasonElement.GetString()
|
||||
: null;
|
||||
entries.Add((id, reason));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
string connector = i < entries.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} Complete #{entries[i].Id}");
|
||||
if (!string.IsNullOrEmpty(entries[i].Reason))
|
||||
{
|
||||
sb.Append($" — {Truncate(entries[i].Reason!, 80)}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ public abstract class ToolCallFormatter
|
||||
[
|
||||
new TodoToolFormatter(),
|
||||
new ModeToolFormatter(),
|
||||
new BackgroundAgentToolFormatter(),
|
||||
new SubAgentToolFormatter(),
|
||||
new FileMemoryToolFormatter(),
|
||||
new WebSearchToolFormatter(),
|
||||
new FallbackToolFormatter(),
|
||||
|
||||
+1
-1
@@ -13,8 +13,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is WebSearchToolResultContent resultContent
|
||||
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
|
||||
{
|
||||
await WriteActionAsync(ux, wscri, resultContent.Outputs);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
|
||||
{
|
||||
WebSearchAction? action = wscri.Action;
|
||||
if (action is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebSearchFindInPageAction findInPage:
|
||||
await WriteFindInPageAsync(ux, findInPage);
|
||||
break;
|
||||
|
||||
case WebSearchOpenPageAction openPage:
|
||||
await WriteOpenPageAsync(ux, openPage);
|
||||
break;
|
||||
|
||||
case WebSearchSearchAction search:
|
||||
await WriteSearchAsync(ux, search, outputs);
|
||||
break;
|
||||
|
||||
default:
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
|
||||
{
|
||||
// Read queries directly from the typed action.
|
||||
IList<string> queries = search.Queries;
|
||||
|
||||
if (queries.Count == 0)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("🌐 Web Search Tool: search");
|
||||
|
||||
// Show the search queries.
|
||||
bool hasResults = outputs is { Count: > 0 };
|
||||
for (int i = 0; i < queries.Count; i++)
|
||||
{
|
||||
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
|
||||
string query = Truncate(queries[i], MaxQueryDisplayLength);
|
||||
sb.Append($"\n {connector} \"{query}\"");
|
||||
}
|
||||
|
||||
// Show search result sources (URLs + titles) when available.
|
||||
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
|
||||
// or directly from the SDK's WebSearchSearchAction.Sources.
|
||||
if (hasResults)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < outputs!.Count; i++)
|
||||
{
|
||||
string connector = i < outputs.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatOutput(outputs[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
else if (search.Sources is { Count: > 0 } sources)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < sources.Count; i++)
|
||||
{
|
||||
string connector = i < sources.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatSource(sources[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
|
||||
{
|
||||
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: open page\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
|
||||
{
|
||||
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
string pattern = findInPage.Pattern ?? "(unknown)";
|
||||
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatSource(WebSearchActionSource source)
|
||||
{
|
||||
if (source is WebSearchActionUriSource uriSource)
|
||||
{
|
||||
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response JSON.
|
||||
string? title = GetTitleFromRawRepresentation(uriSource);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return source.ToString() ?? "(unknown source)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatOutput(AIContent output)
|
||||
{
|
||||
if (output is UriContent uriContent)
|
||||
{
|
||||
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// Try to extract a title from the raw JSON of the source.
|
||||
// The SDK's WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response.
|
||||
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
|
||||
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return output.ToString() ?? "(unknown output)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
|
||||
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
|
||||
/// but the API may include one in the raw JSON — this is forward-compatible for when
|
||||
/// the SDK adds title support.
|
||||
/// </summary>
|
||||
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
|
||||
{
|
||||
if (rawRepresentation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(data);
|
||||
if (doc.RootElement.TryGetProperty("title", out var titleEl)
|
||||
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
return titleEl.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Serialization may not be supported for this object type.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
|
||||
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
|
||||
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
|
||||
// and a WebBrowsingTool.
|
||||
// This sample demonstrates how to use a HarnessAgent with the Harness AIContextProviders
|
||||
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
|
||||
// capabilities powered by Azure AI Foundry.
|
||||
// The agent plans research tasks, creates a todo list, gets user approval,
|
||||
// and then executes each step — all within an interactive conversation loop.
|
||||
//
|
||||
@@ -16,88 +15,147 @@
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.Research";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
|
||||
// as well as HTTP requests made by the underlying HttpClient transport.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// and research-focused instructions including the mandatory planning workflow.
|
||||
var instructions =
|
||||
"""
|
||||
## Research Assistant Instructions
|
||||
|
||||
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
|
||||
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
|
||||
|
||||
### Research quality
|
||||
## Mandatory planning workflow
|
||||
|
||||
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
|
||||
If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below.
|
||||
|
||||
*Plan Mode*
|
||||
|
||||
1. Analyze the request with the purpose of building a research plan.
|
||||
2. Create a list of todo items.
|
||||
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
|
||||
4. Ask for clarifications from the user where needed.
|
||||
1. Ask each clarification one by one.
|
||||
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
|
||||
3. Do not proceed until you have received all the needed clarifications.
|
||||
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
|
||||
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
|
||||
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
|
||||
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
|
||||
|
||||
*Execute Mode*
|
||||
|
||||
1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
|
||||
2. Work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
|
||||
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
|
||||
4. Mark tasks as completed as you finish them.
|
||||
5. Continue working, thinking and calling tools until you have the research result for the user.
|
||||
|
||||
## General Instructions
|
||||
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
- Explain your reasoning and thought process as you work through tasks.
|
||||
- Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
|
||||
- Do not answer the underlying question before the plan has been presented and approved.
|
||||
- This rule applies even when the answer seems obvious or the task seems small.
|
||||
- For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are:
|
||||
- greetings,
|
||||
- pure acknowledgments,
|
||||
- clarification questions needed to form the plan,
|
||||
- follow-up questions about results you have already presented,
|
||||
- meta-discussion about the workflow itself.
|
||||
|
||||
**Todo management**
|
||||
|
||||
Mark each todo complete as you finish it so the list stays current.
|
||||
If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why.
|
||||
Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them.
|
||||
|
||||
**Research quality**
|
||||
|
||||
Consult multiple sources when possible and cross-reference key claims.
|
||||
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
|
||||
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
|
||||
Track your sources — you will need them when presenting results.
|
||||
|
||||
### Presenting results
|
||||
**Presenting results**
|
||||
|
||||
When presenting your final findings:
|
||||
- Use Markdown formatting for clarity.
|
||||
- Use clear sections with headings for each major topic or sub-question.
|
||||
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
||||
- End with a brief summary of key takeaways.
|
||||
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
|
||||
- Save the final research report to file memory so it survives compaction and can be referenced later.
|
||||
|
||||
**File memory**
|
||||
|
||||
Use the FileMemory_* tools to:
|
||||
- Store downloaded search results or web pages.
|
||||
- Store plans.
|
||||
- Read the current plan to make sure tasks were done according to plan.
|
||||
- Store findings.
|
||||
- Check for relevant previously downloaded data / findings before starting new research.
|
||||
""";
|
||||
|
||||
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
|
||||
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
|
||||
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
|
||||
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
|
||||
// per-service-call chat history persistence, and in-loop compaction.
|
||||
// Then wrap with UseToolApproval to allow auto-approving tools once confirmed.
|
||||
AIAgent agent =
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new OpenAIClient(
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||
.GetProjectOpenAIClient()
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
|
||||
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
|
||||
Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
AIContextProviders =
|
||||
[
|
||||
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
||||
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
})
|
||||
.AsBuilder()
|
||||
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
|
||||
.Build();
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
@@ -105,14 +163,12 @@ await HarnessConsole.RunAgentAsync(
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
|
||||
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
||||
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
|
||||
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
|
||||
// tool configuration is needed on the background agent.
|
||||
//
|
||||
// Special commands:
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.SubAgents";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// Create the AIProjectClient for communicating with the Foundry responses service.
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
// --- Background agent: Web Search Agent ---
|
||||
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
|
||||
// Features not needed by this sub-agent are disabled.
|
||||
AIAgent webSearchAgent =
|
||||
projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
},
|
||||
});
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the background agent to look up stock prices in parallel.
|
||||
var parentInstructions =
|
||||
"""
|
||||
You are a stock price research assistant. You have access to a web search background agent that can look up information on the web.
|
||||
|
||||
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
||||
- Start all background tasks before waiting for any of them to complete, so they run concurrently.
|
||||
2. Wait for all background tasks to complete.
|
||||
3. Retrieve the results from each background task.
|
||||
4. Present a summary table with the ticker symbol and closing price for each stock.
|
||||
5. Clear all completed tasks to free memory.
|
||||
|
||||
## Important
|
||||
|
||||
- Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory.
|
||||
- If a background task fails or returns unclear results, continue the task with a more specific query.
|
||||
- Present results in a clean markdown table format.
|
||||
""";
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||
// Most features are disabled since the parent only needs SubAgentsProvider.
|
||||
AIAgent parentAgent =
|
||||
projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using background agents.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
AIContextProviders =
|
||||
[
|
||||
new BackgroundAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
+1
-1
@@ -13,8 +13,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
|
||||
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
||||
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
|
||||
// equipped with Foundry's hosted web search tool.
|
||||
//
|
||||
// Special commands:
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
|
||||
// --- Sub-agent: Web Search Agent ---
|
||||
// This agent can search the web and is used by the parent agent to look up stock prices.
|
||||
AIAgent webSearchAgent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||
var parentInstructions =
|
||||
"""
|
||||
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
|
||||
|
||||
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
||||
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
|
||||
2. Wait for all sub-tasks to complete.
|
||||
3. Retrieve the results from each sub-task.
|
||||
4. Present a summary table with the ticker symbol and closing price for each stock.
|
||||
5. Clear all completed tasks to free memory.
|
||||
|
||||
## Important
|
||||
|
||||
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
|
||||
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
|
||||
- Present results in a clean markdown table format.
|
||||
""";
|
||||
|
||||
AIAgent parentAgent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using sub-agents.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new SubAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
+15
-15
@@ -1,24 +1,24 @@
|
||||
# Harness Step 02 — BackgroundAgents (Stock Price Research)
|
||||
# Harness Step 02 — SubAgents (Stock Price Research)
|
||||
|
||||
This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
|
||||
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
|
||||
|
||||
## What It Does
|
||||
|
||||
A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table.
|
||||
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ BackgroundAgentsProvider │
|
||||
│ ├─ BackgroundAgents_StartTask │
|
||||
│ ├─ BackgroundAgents_WaitFor... │
|
||||
│ ├─ BackgroundAgents_GetTaskResults │
|
||||
│ └─ ... │
|
||||
└────────────┬───────────────────────────┘
|
||||
┌─────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ SubAgentsProvider │
|
||||
│ ├─ SubAgents_StartTask │
|
||||
│ ├─ SubAgents_WaitFor... │
|
||||
│ ├─ SubAgents_GetTaskResults │
|
||||
│ └─ ... │
|
||||
└────────────┬────────────────────┘
|
||||
│ delegates to
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
@@ -40,7 +40,7 @@ A parent agent receives a list of stock tickers and uses a web-search background
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents
|
||||
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
|
||||
dotnet run
|
||||
```
|
||||
|
||||
@@ -50,4 +50,4 @@ When prompted, enter a list of stock tickers such as:
|
||||
BAC, MSFT, BA
|
||||
```
|
||||
|
||||
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
|
||||
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
|
||||
+2
-2
@@ -13,13 +13,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
|
||||
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
|
||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
||||
// and extract information from the data, then write results back as new files.
|
||||
//
|
||||
// The sample includes a pre-populated `working/` folder with sales transaction data.
|
||||
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
|
||||
// which matches this sample's folder layout.
|
||||
// The sample includes a pre-populated `data/` folder with sales transaction data.
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
@@ -16,21 +14,22 @@
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.DataProcessing";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
// Point the file store at the data/ folder that ships with the sample.
|
||||
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
|
||||
var fileStore = new FileSystemAgentFileStore(dataFolder);
|
||||
|
||||
var instructions =
|
||||
"""
|
||||
@@ -57,27 +56,25 @@ var instructions =
|
||||
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
""";
|
||||
|
||||
// Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the
|
||||
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
|
||||
// Unused features are disabled.
|
||||
// Create the chat client from the OpenAI provider.
|
||||
AIAgent agent =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
|
||||
.GetProjectOpenAIClient()
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableWebSearch = true,
|
||||
AIContextProviders =
|
||||
[
|
||||
new FileAccessProvider(fileStore),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
|
||||
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction — so the sample only needs to supply the chat client, token limits, and application-specific options.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
|
||||
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
|
||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
||||
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
@@ -39,7 +39,7 @@ dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
|
||||
|
||||
## What to Expect
|
||||
|
||||
The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
||||
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
||||
|
||||
You can ask the agent to:
|
||||
|
||||
@@ -53,7 +53,7 @@ E.g. try the following prompt `Please process the sales.csv file by first filter
|
||||
|
||||
## Sample Data
|
||||
|
||||
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a HarnessAgent with ALL features enabled, plus:
|
||||
// - Hyperlight CodeAct (HyperlightCodeActProvider) for sandboxed Python code execution
|
||||
// - Skills (AgentSkillsProvider) discovering a local "regex-tester" skill
|
||||
//
|
||||
// The agent can plan tasks with todos, manage modes, store memories, read/write files,
|
||||
// search the web, approve sensitive tools, discover and use skills, and execute arbitrary
|
||||
// Python code in a Hyperlight sandbox — all pre-configured by the HarnessAgent.
|
||||
//
|
||||
// Try asking: "Help me write a regex that matches valid email addresses, then test it."
|
||||
//
|
||||
// Special commands:
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using HyperlightSandbox.Guest.Python;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.CodeExecution";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// Create the HyperlightCodeActProvider with the Python/Wasm backend.
|
||||
// The guest module path is resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
|
||||
using var codeAct = new HyperlightCodeActProvider(
|
||||
HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
|
||||
|
||||
var instructions =
|
||||
"""
|
||||
## Technical Assistant Instructions
|
||||
|
||||
You are a code-powered technical assistant. You can execute Python code in a sandboxed environment
|
||||
to solve problems precisely rather than guessing. You also have access to skills that provide
|
||||
structured workflows for specific technical tasks.
|
||||
|
||||
### Code Execution
|
||||
|
||||
When a problem requires computation, validation, or testing:
|
||||
- Write Python code and use `execute_code` to run it in the sandbox.
|
||||
- Always verify results by running the code rather than reasoning about what would happen.
|
||||
- If code fails, read the error message carefully, fix the issue, and retry.
|
||||
|
||||
### Skills
|
||||
|
||||
You have access to discoverable skills. When a task matches a skill's description:
|
||||
- Follow the skill's instructions carefully.
|
||||
- Use the skill's reference materials for context.
|
||||
- Combine the skill's workflow with code execution when appropriate.
|
||||
|
||||
### Planning and Research
|
||||
|
||||
For complex tasks:
|
||||
- Break the problem into steps using your todo list.
|
||||
- Research background information using web search when needed.
|
||||
- Save important findings to file memory for later reference.
|
||||
|
||||
### Presenting Results
|
||||
|
||||
- Show your work: include the code you ran and its output.
|
||||
- Explain what each part of your solution does.
|
||||
- If applicable, save final results to file memory.
|
||||
""";
|
||||
|
||||
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
|
||||
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
|
||||
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
|
||||
AIAgent agent =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||
{
|
||||
Name = "CodeExecutionAgent",
|
||||
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
// Point the file memory at a local folder for persistent memory across sessions.
|
||||
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
|
||||
AIContextProviders = [codeAct],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me a technical question, or try: \"Help me write a regex that matches valid email addresses.\"",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens),
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
# Harness Step 04 — Code Execution (Hyperlight + Skills)
|
||||
|
||||
This sample demonstrates a HarnessAgent with **all features enabled**, plus:
|
||||
|
||||
- **Hyperlight CodeAct** — sandboxed Python code execution via `execute_code` (requires KVM)
|
||||
- **Skills** — file-based skill discovery (a `regex-tester` skill is included)
|
||||
|
||||
The agent can plan tasks, manage modes, store memories, read/write files, search the web, approve sensitive operations, discover and use skills, and execute arbitrary Python code — all pre-configured by the HarnessAgent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK
|
||||
- An Azure AI Foundry project endpoint
|
||||
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name (default: `gpt-5.4`) |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What to Try
|
||||
|
||||
- **Regex testing**: "Help me write a regex that matches valid email addresses, then test it against some examples."
|
||||
- **Code execution**: "Calculate the first 20 prime numbers using the Sieve of Eratosthenes."
|
||||
- **Skill + code combo**: "I need a regex for ISO 8601 dates — test it thoroughly with edge cases."
|
||||
|
||||
## Included Skill
|
||||
|
||||
The `skills/regex-tester/` skill instructs the agent to validate regex patterns by executing Python test code in the Hyperlight sandbox. It includes a regex cheatsheet as reference material.
|
||||
|
||||
## Features Enabled
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| TodoProvider | Task planning and tracking (`/todos` command) |
|
||||
| AgentModeProvider | Mode switching (`/mode` command) |
|
||||
| FileMemoryProvider | Persistent memory stored as files |
|
||||
| FileAccessProvider | Read/write files in a working directory |
|
||||
| ToolApproval | Don't-ask-again approval for sensitive tools |
|
||||
| WebSearch | Built-in hosted web search |
|
||||
| AgentSkillsProvider | Discovers and uses skills from the `skills/` folder |
|
||||
| HyperlightCodeActProvider | Sandboxed Python execution via `execute_code` |
|
||||
| OpenTelemetry | Trace logging to a text file |
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: regex-tester
|
||||
description: Validate, test, and debug regular expressions by executing them against sample inputs. Use when asked to build, verify, or explain a regex pattern.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks you to create, validate, or debug a regular expression:
|
||||
|
||||
1. **Understand the requirement** — clarify what the pattern should match and what it should reject.
|
||||
2. **Consult the cheatsheet** — review `references/regex-cheatsheet.md` for syntax reminders if needed.
|
||||
3. **Write and execute test code** — use the `execute_code` tool to run Python code that:
|
||||
- Compiles the regex with `re.compile()`
|
||||
- Tests it against a set of positive examples (should match) and negative examples (should not match)
|
||||
- Extracts and displays any capturing groups
|
||||
- Reports pass/fail for each test case
|
||||
4. **Iterate** — if any test fails, refine the pattern and re-run until all cases pass.
|
||||
5. **Present the result** — give the user the final pattern, explain what each part does, and show the test results.
|
||||
|
||||
## Example Test Script
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
|
||||
|
||||
positives = ["user@example.com", "first.last+tag@sub.domain.org"]
|
||||
negatives = ["@missing.com", "no-at-sign", "spaces in@address.com"]
|
||||
|
||||
for s in positives:
|
||||
assert pattern.match(s), f"FAIL: expected match for '{s}'"
|
||||
for s in negatives:
|
||||
assert not pattern.match(s), f"FAIL: expected no match for '{s}'"
|
||||
|
||||
print("All tests passed!")
|
||||
```
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
# Regex Quick Reference (Python `re` module)
|
||||
|
||||
## Character Classes
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|---------|
|
||||
| `.` | Any character except newline |
|
||||
| `\d` | Digit `[0-9]` |
|
||||
| `\D` | Non-digit |
|
||||
| `\w` | Word character `[a-zA-Z0-9_]` |
|
||||
| `\W` | Non-word character |
|
||||
| `\s` | Whitespace `[ \t\n\r\f\v]` |
|
||||
| `\S` | Non-whitespace |
|
||||
| `[abc]` | Any of a, b, or c |
|
||||
| `[^abc]`| Any character except a, b, c |
|
||||
| `[a-z]` | Range: a through z |
|
||||
|
||||
## Quantifiers
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| `*` | 0 or more (greedy) |
|
||||
| `+` | 1 or more (greedy) |
|
||||
| `?` | 0 or 1 (greedy) |
|
||||
| `{n}` | Exactly n |
|
||||
| `{n,}` | n or more |
|
||||
| `{n,m}` | Between n and m |
|
||||
| `*?`, `+?`, `??` | Non-greedy versions |
|
||||
|
||||
## Anchors
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| `^` | Start of string (or line with `re.MULTILINE`) |
|
||||
| `$` | End of string (or line with `re.MULTILINE`) |
|
||||
| `\b` | Word boundary |
|
||||
| `\B` | Non-word boundary |
|
||||
|
||||
## Groups and Backreferences
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| `(...)` | Capturing group |
|
||||
| `(?:...)`| Non-capturing group |
|
||||
| `(?P<name>...)` | Named group |
|
||||
| `\1` | Backreference to group 1 |
|
||||
| `(?=...)` | Positive lookahead |
|
||||
| `(?!...)` | Negative lookahead |
|
||||
| `(?<=...)` | Positive lookbehind |
|
||||
| `(?<!...)` | Negative lookbehind |
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `re.IGNORECASE` / `re.I` | Case-insensitive matching |
|
||||
| `re.MULTILINE` / `re.M` | `^`/`$` match line boundaries |
|
||||
| `re.DOTALL` / `re.S` | `.` matches newline |
|
||||
| `re.VERBOSE` / `re.X` | Allow comments and whitespace |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
| Use Case | Pattern |
|
||||
|----------|---------|
|
||||
| Email (simple) | `^[\w.+-]+@[\w-]+\.[\w.-]+$` |
|
||||
| IPv4 address | `^\d{1,3}(\.\d{1,3}){3}$` |
|
||||
| ISO date | `^\d{4}-\d{2}-\d{2}$` |
|
||||
| URL (http/https) | `^https?://[^\s/$.?#].[^\s]*$` |
|
||||
| Phone (US) | `^(\+1)?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$` |
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
# Test if a string matches
|
||||
re.match(r'pattern', "string") # match at start
|
||||
re.search(r'pattern', "string") # match anywhere
|
||||
re.fullmatch(r'pattern', "string") # match entire string
|
||||
|
||||
# Find all matches
|
||||
re.findall(r'\d+', "abc 123 def 456") # ['123', '456']
|
||||
|
||||
# Named groups
|
||||
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', "2025-01-15")
|
||||
m.group('year') # '2025'
|
||||
|
||||
# Replace
|
||||
re.sub(r'\d+', 'X', "abc 123 def") # 'abc X def'
|
||||
|
||||
# Split
|
||||
re.split(r',+', "a,b,,c") # ['a', 'b', 'c']
|
||||
|
||||
# Compile for reuse
|
||||
pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
||||
pattern.match("2025-01-15") # Match object
|
||||
```
|
||||
@@ -7,5 +7,5 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
|
||||
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
|
||||
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
|
||||
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
|
||||
|
||||
+1
-2
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
@@ -22,7 +22,6 @@
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
|
||||
+4
-6
@@ -14,7 +14,6 @@ using Azure.Identity;
|
||||
using Azure.Search.Documents;
|
||||
using Azure.Search.Documents.Models;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -67,15 +66,14 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
-1
@@ -18,7 +18,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+50
-6
@@ -4,7 +4,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -41,14 +40,59 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
@@ -28,7 +28,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -35,7 +35,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -176,15 +175,14 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
-1
@@ -18,7 +18,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+50
-6
@@ -5,7 +5,6 @@ using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -34,14 +33,59 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+40
-6
@@ -11,7 +11,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -113,18 +112,53 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -21,7 +21,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -19,7 +19,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -82,14 +81,50 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected isolation keys are absent.
|
||||
# These two variables provide fallback values for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
HOSTED_CHAT_ISOLATION_KEY=local-dev-chat
|
||||
@@ -1,26 +0,0 @@
|
||||
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||
#
|
||||
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||
# commented-out section in HostedMemoryAgent.csproj). Contributors building from the
|
||||
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||
# this build context.
|
||||
#
|
||||
# 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", "HostedMemoryAgent.dll"]
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry 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-memory-agent .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-memory-agent \
|
||||
# -e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
# -e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
# --env-file .env hosted-memory-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", "HostedMemoryAgent.dll"]
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedMemoryAgent</RootNamespace>
|
||||
<AssemblyName>HostedMemoryAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted-MemoryAgent
|
||||
//
|
||||
// Demonstrates how to host an agent that uses FoundryMemoryProvider so that user-private memories
|
||||
// persist across requests and across sessions, scoped per user via the Foundry platform's
|
||||
// isolation key headers.
|
||||
//
|
||||
// Memory scope flows from request -> hosting layer -> session -> provider:
|
||||
// 1. Foundry sets x-agent-user-isolation-key on every inbound request.
|
||||
// 2. AgentFrameworkResponseHandler reads context.Isolation.UserIsolationKey via the registered
|
||||
// HostedSessionIsolationKeyProvider and stores it on the session as a HostedSessionContext.
|
||||
// 3. FoundryMemoryProvider's stateInitializer reads HostedSessionContext.UserId and uses it as
|
||||
// the FoundryMemoryProviderScope, partitioning memories per user.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development).
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
AIProjectClient projectClient = new(projectEndpoint, credential);
|
||||
|
||||
// FoundryMemoryProvider partitions memories per end user via a built-in HostedFoundryMemoryProviderScopes
|
||||
// helper that reads the platform-injected user isolation key from the HostedSessionContext that the
|
||||
// hosting layer placed on the session.
|
||||
FoundryMemoryProvider memoryProvider = new(
|
||||
projectClient,
|
||||
memoryStoreName,
|
||||
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
|
||||
// Provision the memory store on startup if it does not already exist. EnsureMemoryStoreCreatedAsync
|
||||
// is idempotent. Doing this once at start avoids per-request latency.
|
||||
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embeddingDeployment, "Memory store for the hosted travel-assistant sample.");
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a friendly travel assistant. When the user shares trip preferences, destinations,
|
||||
travel companions, or constraints, remember them and use them in later turns. Use known
|
||||
memories about the user when responding, and do not invent details.
|
||||
""";
|
||||
|
||||
ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Name = agentName,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = AgentInstructions
|
||||
},
|
||||
AIContextProviders = [memoryProvider]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
@@ -1,155 +0,0 @@
|
||||
# Hosted-MemoryAgent
|
||||
|
||||
A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across
|
||||
requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The
|
||||
agent plays a friendly travel assistant: tell it about your trip, ask follow-up questions in a new
|
||||
session, and it recalls what it learned about you.
|
||||
|
||||
This sample exists to demonstrate two things together:
|
||||
|
||||
1. How to host an agent that consumes a `Microsoft.Extensions.AI.AIContextProvider` (specifically
|
||||
`FoundryMemoryProvider`) under the Foundry Responses hosting layer.
|
||||
2. How the new `HostedSessionContext` flows from the `Foundry` platform isolation headers
|
||||
(`x-agent-user-isolation-key`, `x-agent-chat-isolation-key`) through the
|
||||
`HostedSessionIsolationKeyProvider` into the provider's `stateInitializer`, so memories are
|
||||
partitioned per user automatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with at least one chat model deployment and one embedding model deployment
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
For local container runs only (the platform supplies these in production):
|
||||
|
||||
```env
|
||||
HOSTED_USER_ISOLATION_KEY=alice
|
||||
HOSTED_CHAT_ISOLATION_KEY=alice-chat-1
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## How memory scoping works
|
||||
|
||||
| Layer | Source of the user identity |
|
||||
|---|---|
|
||||
| Inbound request | The Foundry platform sets `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every request. |
|
||||
| Hosting layer | `AgentFrameworkResponseHandler` resolves a `HostedSessionIsolationKeyProvider` from DI and calls `GetKeysAsync(context, request, ct)`. The default implementation reads `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. |
|
||||
| Session | The handler stores the resolved values on the session as a `HostedSessionContext` on the first request, and validates the values on every subsequent request that resumes the same conversation (mismatch returns 403). |
|
||||
| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. |
|
||||
|
||||
When running outside the Foundry platform the headers are absent. The sample registers
|
||||
`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which
|
||||
falls back to the `HOSTED_USER_ISOLATION_KEY` and `HOSTED_CHAT_ISOLATION_KEY` environment variables,
|
||||
defaulting to a single `local-dev-*` bucket when neither is set.
|
||||
|
||||
> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in
|
||||
> production. The Foundry platform sets the isolation keys for every inbound request, and
|
||||
> client-supplied environment variables can be forged.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
Wait a few seconds for memory extraction, then ask a follow-up using the response id from the
|
||||
previous call as `previous_response_id`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
## 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-memory-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
```bash
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-memory-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
-e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=alice-chat-1 \
|
||||
--env-file .env \
|
||||
hosted-memory-agent
|
||||
```
|
||||
|
||||
### 4. Smoke test the running container
|
||||
|
||||
A scripted smoke test that exercises memory recall and per-user isolation across two simulated
|
||||
users is provided at `scripts/smoke.ps1`. From the sample folder:
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/smoke.ps1
|
||||
```
|
||||
|
||||
The script publishes the project, builds the image, runs the container with two distinct
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## 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
|
||||
`HostedMemoryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## How it differs from sibling samples
|
||||
|
||||
| | Hosted-ChatClientAgent | Hosted-MemoryAgent |
|
||||
|---|---|---|
|
||||
| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` |
|
||||
| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory |
|
||||
| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope |
|
||||
| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users |
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-memory-agent
|
||||
displayName: "Hosted Memory Agent"
|
||||
|
||||
description: >
|
||||
A travel-assistant hosted agent that uses FoundryMemoryProvider to remember user-private
|
||||
preferences and details across sessions. Memory is scoped per end user via the Foundry
|
||||
platform's isolation key headers.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
- Memory
|
||||
- Foundry Memory
|
||||
|
||||
template:
|
||||
name: hosted-memory-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-memory-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-MemoryAgent sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container twice with two
|
||||
distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl
|
||||
invocations, and asserts that each user only sees their own remembered details.
|
||||
Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the isolation
|
||||
keys for every inbound request in production and the dev fallback used here must not be
|
||||
enabled in production deployments.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8088,
|
||||
[string]$ImageName = 'hosted-memory-agent-smoke',
|
||||
[int]$RecallDelaySeconds = 25
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location -Path $PSScriptRoot/..
|
||||
|
||||
if (-not (Test-Path .env)) {
|
||||
throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.'
|
||||
}
|
||||
|
||||
Write-Host '==> Publishing sample for linux-musl-x64 ...'
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||
|
||||
Write-Host '==> Building docker image ...'
|
||||
docker build -f Dockerfile.contributor -t $ImageName . | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'docker build failed.' }
|
||||
|
||||
Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container([string]$UserKey, [string]$ChatKey, [string]$ContainerName) {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-memory-agent `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=$UserKey `
|
||||
-e HOSTED_CHAT_ISOLATION_KEY=$ChatKey `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
|
||||
# Wait briefly for the listener to come up.
|
||||
Start-Sleep -Seconds 6
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-memory-agent' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -notmatch [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: expected response to contain '$Needle' but got: $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response contains '$Needle'."
|
||||
}
|
||||
|
||||
function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label) {
|
||||
if ($Haystack -match [regex]::Escape($Needle)) {
|
||||
throw "FAILED [$Label]: response unexpectedly contains '$Needle': $Haystack"
|
||||
}
|
||||
Write-Host "PASS [$Label]: response does not contain '$Needle'."
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
|
||||
Start-Container -UserKey 'alice' -ChatKey 'alice-chat-1' -ContainerName 'hosted-memory-smoke-alice'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
|
||||
$r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
|
||||
|
||||
Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..."
|
||||
Start-Sleep -Seconds $RecallDelaySeconds
|
||||
|
||||
$r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
|
||||
$aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia'
|
||||
|
||||
docker rm -f hosted-memory-smoke-alice | Out-Null
|
||||
|
||||
Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...'
|
||||
Start-Container -UserKey 'bob' -ChatKey 'bob-chat-1' -ContainerName 'hosted-memory-smoke-bob'
|
||||
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
|
||||
$bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null
|
||||
docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null
|
||||
}
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
+40
-6
@@ -10,7 +10,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -61,14 +60,49 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -48,15 +47,14 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -99,3 +97,34 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(GetAccessToken());
|
||||
|
||||
private static AccessToken GetAccessToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
|
||||
@@ -21,7 +21,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -58,7 +57,6 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
@@ -69,11 +67,47 @@ builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -24,7 +24,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
|
||||
+28
-1
@@ -17,9 +17,9 @@
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
@@ -192,3 +192,30 @@ static string GetWeather(
|
||||
var condition = conditions[rng.Next(conditions.Length)];
|
||||
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
|
||||
}
|
||||
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -20,7 +20,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+40
-6
@@ -9,7 +9,6 @@ using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -50,14 +49,49 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
// Host the workflow agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="HostedSessionIsolationKeyProvider"/> for local Docker debugging only.
|
||||
///
|
||||
/// When the Foundry platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers are absent (i.e., when the container is running
|
||||
/// outside the Foundry platform), the hosting layer rejects every request with a 500 because the
|
||||
/// default <see cref="HostedSessionIsolationKeyProvider"/> returns null. This provider supplies
|
||||
/// fallback values from the <c>HOSTED_USER_ISOLATION_KEY</c> and <c>HOSTED_CHAT_ISOLATION_KEY</c>
|
||||
/// environment variables, defaulting to the constants below when neither is set.
|
||||
///
|
||||
/// This should NOT be used in production. The Foundry platform sets the isolation keys for every
|
||||
/// inbound request and forging them client-side defeats the per-user partitioning. The dev
|
||||
/// fallback exists solely so a contributor can <c>docker run</c> the sample on their laptop and
|
||||
/// drive a few requests end to end.
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the user isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the chat isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string ChatIsolationKeyEnvironmentVariable = "HOSTED_CHAT_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Default user isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value. All local requests collapse onto this single bucket unless overridden.
|
||||
/// </summary>
|
||||
public const string DefaultLocalUserIsolationKey = "local-dev-user";
|
||||
|
||||
/// <summary>
|
||||
/// Default chat isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value.
|
||||
/// </summary>
|
||||
public const string DefaultLocalChatIsolationKey = "local-dev-chat";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = !string.IsNullOrWhiteSpace(context?.Isolation?.UserIsolationKey)
|
||||
? context!.Isolation!.UserIsolationKey
|
||||
: Environment.GetEnvironmentVariable(UserIsolationKeyEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
userKey = DefaultLocalUserIsolationKey;
|
||||
}
|
||||
|
||||
var chatKey = !string.IsNullOrWhiteSpace(context?.Isolation?.ChatIsolationKey)
|
||||
? context!.Isolation!.ChatIsolationKey
|
||||
: Environment.GetEnvironmentVariable(ChatIsolationKeyEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(chatKey))
|
||||
{
|
||||
chatKey = DefaultLocalChatIsolationKey;
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production. Tokens expire (around one hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevTemporaryTokenCredential"/> class.
|
||||
/// Reads the bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable when present.
|
||||
/// </summary>
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Routing helpers for contributor samples that host a Foundry-managed agent locally.
|
||||
/// </summary>
|
||||
public static class HostedContributorRouteExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// In Development, maps the per-agent OpenAI route shape that live Foundry uses
|
||||
/// (<c>/api/projects/{project}/agents/{agentName}/endpoint/protocols/openai/responses</c>) on top
|
||||
/// of the default <c>MapFoundryResponses()</c> so a local REPL client can reach the agent through
|
||||
/// <c>AIProjectClient.AsAIAgent(Uri agentEndpoint)</c>, which is the only supported consumption path
|
||||
/// for Foundry-hosted agents.
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>{project}</c> and <c>{agentName}</c> segments are route-parameter wildcards on the server
|
||||
/// side; the handler does not consume them, so any value sent by the client is accepted.
|
||||
/// </para>
|
||||
///
|
||||
/// <para><b>For local contributor debugging only and should not be used in production.</b></para>
|
||||
/// </summary>
|
||||
/// <param name="app">The <see cref="WebApplication"/> to attach the routes to.</param>
|
||||
/// <returns>The same <see cref="WebApplication"/> for chaining.</returns>
|
||||
public static WebApplication MapDevTemporaryLocalAgentEndpoint(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("api/projects/{project}/agents/{agentName}/endpoint/protocols/openai");
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Registration helpers for the developer-only utilities shipped in this sample-shared project.
|
||||
/// </summary>
|
||||
public static class HostedContributorSetupExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers developer-only services that allow a hosted Foundry agent to run outside the
|
||||
/// Foundry platform (e.g., inside a Docker container during contributor debugging).
|
||||
///
|
||||
/// <para><b>For local Docker debugging only and should not be used in production.</b></para>
|
||||
///
|
||||
/// Currently this method registers a <see cref="DevTemporaryLocalSessionIsolationKeyProvider"/>
|
||||
/// so that requests succeed when the platform's <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers are absent. In production those headers are
|
||||
/// always present and the default platform isolation key provider (registered automatically by
|
||||
/// the hosting layer) is used instead.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register the developer-only services into.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddDevTemporaryLocalContributorSetup(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<HostedSessionIsolationKeyProvider, DevTemporaryLocalSessionIsolationKeyProvider>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>Hosted_Shared_Contributor_Setup</RootNamespace>
|
||||
<AssemblyName>Hosted_Shared_Contributor_Setup</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+12
-17
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
@@ -10,34 +11,28 @@ using Microsoft.Agents.AI.Foundry;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
|
||||
// https://<host>/api/projects/<project>
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
|
||||
?? "http://localhost:8088");
|
||||
|
||||
// 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.");
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("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 ──────
|
||||
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
if (agentEndpoint.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);
|
||||
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -46,10 +41,10 @@ AgentSession session = await agent.CreateSessionAsync();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Session Files Client
|
||||
Session Files Client
|
||||
Connected to: {agentEndpoint}
|
||||
Try: "Give me the total revenue in the contoso file."
|
||||
Type a message or 'quit' to exit
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
""");
|
||||
Console.ResetColor();
|
||||
|
||||
+5
-5
@@ -13,18 +13,18 @@ The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
|
||||
AZURE_AI_AGENT_NAME=hosted-files
|
||||
AGENT_ENDPOINT=http://localhost:8088
|
||||
AGENT_NAME=hosted-files
|
||||
```
|
||||
|
||||
Both are required. `AZURE_AI_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.
|
||||
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-files"
|
||||
$env:AGENT_ENDPOINT = "http://localhost:8088"
|
||||
$env:AGENT_NAME = "hosted-files"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
|
||||
+11
-16
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
@@ -10,34 +11,28 @@ using Microsoft.Agents.AI.Foundry;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
|
||||
// https://<host>/api/projects/<project>
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
|
||||
?? "http://localhost:8088");
|
||||
|
||||
// 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");
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
|
||||
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
if (agentEndpoint.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);
|
||||
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -46,9 +41,9 @@ AgentSession session = await agent.CreateSessionAsync();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple Agent Sample
|
||||
Simple Agent Sample
|
||||
Connected to: {agentEndpoint}
|
||||
Type a message or 'quit' to exit
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
""");
|
||||
Console.ResetColor();
|
||||
|
||||
@@ -458,9 +458,8 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
// This ensures all AGUI events have a valid messageId regardless of agent type.
|
||||
if (string.IsNullOrWhiteSpace(chatResponse.MessageId))
|
||||
{
|
||||
chatResponse.MessageId = ContainsToolResult(chatResponse)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: (streamingMessageId ??= Guid.NewGuid().ToString("N"));
|
||||
streamingMessageId ??= Guid.NewGuid().ToString("N");
|
||||
chatResponse.MessageId = streamingMessageId;
|
||||
}
|
||||
|
||||
if (chatResponse is { Contents.Count: > 0 } &&
|
||||
@@ -726,17 +725,4 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
_ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsToolResult(ChatResponseUpdate chatResponse)
|
||||
{
|
||||
foreach (AIContent content in chatResponse.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
private readonly FoundryToolboxService? _toolboxService;
|
||||
|
||||
/// <summary>
|
||||
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
|
||||
/// Avoids a per-request allocation on the request hot path.
|
||||
/// </summary>
|
||||
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
@@ -73,42 +67,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
|
||||
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 2.5. Resolve and apply the per-request hosted session identity context.
|
||||
// Fresh sessions are tagged once. Resumed sessions are validated against the live request
|
||||
// to detect cross-user session leaks and in-process tampering of the persisted identity.
|
||||
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
|
||||
?? s_defaultIsolationKeyProvider;
|
||||
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
|
||||
if (resolvedHostedContext is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " +
|
||||
"Ensure the Foundry platform is providing the x-agent-user-isolation-key and x-agent-chat-isolation-key headers, " +
|
||||
"or register a custom provider that supplies fallback values for local development.");
|
||||
}
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var existingHostedContext = session.GetHostedContext();
|
||||
if (existingHostedContext is null)
|
||||
{
|
||||
// Fresh path: the session has no hosted context yet (either freshly created here,
|
||||
// or freshly loaded for a conversation_id that the platform supplied without any
|
||||
// prior hosted-agent request having stamped a context). Stamp it now.
|
||||
session.SetHostedContext(resolvedHostedContext);
|
||||
}
|
||||
else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal)
|
||||
|| !string.Equals(existingHostedContext.ChatId, resolvedHostedContext.ChatId, StringComparison.Ordinal))
|
||||
{
|
||||
// Resume path: the persisted identity must match the live request. A mismatch
|
||||
// signals either a cross-user session leak or in-process tampering of the
|
||||
// persisted identity. Reject the request hard.
|
||||
throw new ResponsesApiException(
|
||||
new Error("hosted_session_identity_mismatch", "Hosted session identity context mismatch"),
|
||||
403);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in <see cref="FoundryMemoryProvider"/> <c>stateInitializer</c> factories that derive the
|
||||
/// <see cref="FoundryMemoryProviderScope"/> from the per-session <see cref="HostedSessionContext"/>
|
||||
/// applied by the Foundry hosting layer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pass the result of any of these helpers as the <c>stateInitializer</c> argument when constructing
|
||||
/// <see cref="FoundryMemoryProvider"/>:
|
||||
/// <code>
|
||||
/// new FoundryMemoryProvider(client, "my-store",
|
||||
/// stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
|
||||
/// </code>
|
||||
/// All helpers throw <see cref="InvalidOperationException"/> when
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/> returns <see langword="null"/>.
|
||||
/// That happens when the agent runs outside the Foundry hosting layer (e.g., a console app); in
|
||||
/// that case write a custom <c>stateInitializer</c> instead of using these helpers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per end user, using
|
||||
/// <see cref="HostedSessionContext.UserId"/> as the partition key.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUser() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per conversation, using
|
||||
/// <see cref="HostedSessionContext.ChatId"/> as the partition key. Use this when memories should
|
||||
/// be visible to every participant in a shared conversation (for example, a Teams group chat).
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerChat() =>
|
||||
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
|
||||
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
|
||||
/// only to the same user within the same conversation.
|
||||
/// </summary>
|
||||
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
|
||||
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
|
||||
session =>
|
||||
{
|
||||
var ctx = GetRequiredHostedContext(session);
|
||||
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
|
||||
};
|
||||
|
||||
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
|
||||
session?.GetHostedContext()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(HostedSessionContext)} was not provided by the hosting layer. " +
|
||||
$"The {nameof(HostedFoundryMemoryProviderScopes)} helpers require the agent to be hosted via the Foundry hosting layer. " +
|
||||
"If running outside a hosted Foundry container, supply a custom stateInitializer to FoundryMemoryProvider instead.");
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that register a <see cref="FoundryMemoryProvider"/> wired with a
|
||||
/// <see cref="HostedFoundryMemoryProviderScopes"/> strategy.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedFoundryMemoryProviderServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> wired to the supplied
|
||||
/// <see cref="AIProjectClient"/> and the supplied <paramref name="stateInitializer"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="client">The <see cref="AIProjectClient"/> used to talk to Foundry Memory.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
AIProjectClient client,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
client,
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a singleton <see cref="FoundryMemoryProvider"/> that resolves its
|
||||
/// <see cref="AIProjectClient"/> from <see cref="IServiceProvider"/> at construction time.
|
||||
/// Use this overload when an <see cref="AIProjectClient"/> is already registered with the
|
||||
/// service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
|
||||
/// <param name="stateInitializer">
|
||||
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
|
||||
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
|
||||
/// Pass any other helper (or a custom delegate) to override.
|
||||
/// </param>
|
||||
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddHostedFoundryMemoryProvider(
|
||||
this IServiceCollection services,
|
||||
string memoryStoreName,
|
||||
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
|
||||
FoundryMemoryProviderOptions? options = null)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrWhitespace(memoryStoreName);
|
||||
|
||||
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
|
||||
services.AddSingleton(sp => new FoundryMemoryProvider(
|
||||
sp.GetRequiredService<AIProjectClient>(),
|
||||
memoryStoreName,
|
||||
initializer,
|
||||
options,
|
||||
sp.GetService<ILoggerFactory>()));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the per-session identity values produced by a <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// when a Foundry hosted agent processes a request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="UserId"/> partitions data that belongs to the individual who initiated the request
|
||||
/// (e.g., personal memory, per-user preferences). The <see cref="ChatId"/> partitions data that belongs
|
||||
/// to the conversation (e.g., conversation history, turn state). Both values are opaque strings whose
|
||||
/// meaning is determined by the active <see cref="HostedSessionIsolationKeyProvider"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Instances are constructed by the hosting layer from the platform-provided
|
||||
/// <c>IsolationContext</c> headers and stored on the session via
|
||||
/// <see cref="HostedSessionContextExtensions.SetHostedContext"/>. Consumers (typically
|
||||
/// <see cref="AIContextProvider"/> implementations) read the values through
|
||||
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class HostedSessionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedSessionContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userId">The opaque user identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <param name="chatId">The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace.</param>
|
||||
/// <exception cref="System.ArgumentException">Thrown when <paramref name="userId"/> or <paramref name="chatId"/> is null or whitespace.</exception>
|
||||
public HostedSessionContext(string userId, string chatId)
|
||||
{
|
||||
this.UserId = Throw.IfNullOrWhitespace(userId);
|
||||
this.ChatId = Throw.IfNullOrWhitespace(chatId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque user identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stable for a given user across sessions. In production this is sourced from the
|
||||
/// <c>x-agent-user-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opaque chat (conversation) identity for this hosted session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In a 1:1 user-to-agent chat this typically equals <see cref="UserId"/>. In shared-surface
|
||||
/// scenarios (e.g., a Teams group chat) it represents the common partition all participants
|
||||
/// write to. In production this is sourced from the <c>x-agent-chat-isolation-key</c> platform header.
|
||||
/// </remarks>
|
||||
public string ChatId { get; }
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for reading and writing the <see cref="HostedSessionContext"/> associated
|
||||
/// with an <see cref="AgentSession"/> in a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hosted session context is written exactly once by the hosting layer when a session is created,
|
||||
/// and is validated against the live request on every subsequent invocation. The <see cref="SetHostedContext"/>
|
||||
/// method is intentionally <see langword="internal"/> so that only the hosting layer can establish the
|
||||
/// identity values; consumers (such as <see cref="AIContextProvider"/> implementations) read the values
|
||||
/// through the public <see cref="GetHostedContext"/> accessor.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class HostedSessionContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The well-known <see cref="AgentSessionStateBag"/> key used to store the
|
||||
/// <see cref="HostedSessionContext"/> on a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed as a constant so consumers can correlate persisted state across processes.
|
||||
/// External code must not write to this key directly; use <see cref="SetHostedContext"/> from the
|
||||
/// hosting assembly instead.
|
||||
/// </remarks>
|
||||
public const string StateKey = "Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="HostedSessionContext"/> previously written by the hosting layer
|
||||
/// for this session, if any.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to read from.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="HostedSessionContext"/> for the session, or <see langword="null"/> when the
|
||||
/// session was not produced by a hosted agent (or the value has not yet been written).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> is <see langword="null"/>.</exception>
|
||||
public static HostedSessionContext? GetHostedContext(this AgentSession session)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
|
||||
return session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out var context, HostedSessionJsonUtilities.DefaultOptions)
|
||||
? context
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <see cref="HostedSessionContext"/> for this session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to write to.</param>
|
||||
/// <param name="context">The hosted session context to associate with <paramref name="session"/>.</param>
|
||||
/// <remarks>
|
||||
/// Internal to the hosting assembly. Consumers must not invoke this method directly; the hosting
|
||||
/// layer is the single writer and uses validation against the live request to detect any tampering
|
||||
/// that does occur via lower-level APIs. Throws when a context has already been written for this
|
||||
/// session to enforce the write-once contract.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> or <paramref name="context"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when this session already carries a <see cref="HostedSessionContext"/>.</exception>
|
||||
internal static void SetHostedContext(this AgentSession session, HostedSessionContext context)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
Throw.IfNull(context);
|
||||
|
||||
if (session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out _, HostedSessionJsonUtilities.DefaultOptions))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A {nameof(HostedSessionContext)} has already been written to this session. " +
|
||||
"The hosted session identity is write-once; resumed sessions must validate against the existing context, not overwrite it.");
|
||||
}
|
||||
|
||||
session.StateBag.SetValue(StateKey, context, HostedSessionJsonUtilities.DefaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the per-request <see cref="HostedSessionContext"/> for a Foundry hosted agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations are invoked once per incoming Responses API request. The returned
|
||||
/// <see cref="HostedSessionContext"/> establishes the identity of a freshly created session and
|
||||
/// is validated against the live request on every subsequent invocation that resumes the same session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation registered when no custom <see cref="HostedSessionIsolationKeyProvider"/>
|
||||
/// is present in DI maps the platform-injected <c>x-agent-user-isolation-key</c> and
|
||||
/// <c>x-agent-chat-isolation-key</c> headers via <see cref="ResponseContext.Isolation"/>. Hosting samples and contributor-only environments
|
||||
/// can register an alternate implementation in DI to provide values when the platform headers are absent
|
||||
/// (e.g., during local Docker debugging).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
|
||||
/// and <see cref="HostedSessionContext.ChatId"/> are both non-null and non-whitespace. Returning either as null
|
||||
/// (or throwing from <see cref="GetKeysAsync"/>) is treated as a configuration error and surfaces as a
|
||||
/// 500 from the hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public abstract class HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <see cref="HostedSessionContext"/> for the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="context">The per-request <see cref="ResponseContext"/> from the Azure AI Responses Server SDK.</param>
|
||||
/// <param name="request">The <see cref="CreateResponse"/> describing the incoming request.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/> and
|
||||
/// <see cref="HostedSessionContext.ChatId"/>, or <see langword="null"/> when the implementation cannot
|
||||
/// produce identity keys for the current request. A <see langword="null"/> result is treated as a
|
||||
/// configuration error by the hosting layer and surfaces as 500.
|
||||
/// </returns>
|
||||
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization utilities for hosted session identity types.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal static class HostedSessionJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Default JSON serializer options for hosted session state.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
TypeInfoResolver = HostedSessionJsonContext.Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serialization context for hosted session identity types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.General,
|
||||
UseStringEnumConverter = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(HostedSessionContext))]
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal partial class HostedSessionJsonContext : JsonSerializerContext;
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="HostedSessionIsolationKeyProvider"/> implementation that maps the platform-injected
|
||||
/// <c>x-agent-user-isolation-key</c> and <c>x-agent-chat-isolation-key</c> headers from
|
||||
/// <see cref="ResponseContext.Isolation"/> into a <see cref="HostedSessionContext"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the implementation used in production Foundry hosted environments. When running locally
|
||||
/// outside the platform, both isolation keys are <see langword="null"/>, which causes
|
||||
/// <see cref="GetKeysAsync"/> to return <see langword="null"/>. The hosting layer treats a null
|
||||
/// result as a configuration error and surfaces it as a 500 from the request. Local development
|
||||
/// should register an alternate <see cref="HostedSessionIsolationKeyProvider"/> implementation
|
||||
/// that provides fallback values for the missing headers.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = context?.Isolation?.UserIsolationKey;
|
||||
var chatKey = context?.Isolation?.ChatIsolationKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey))
|
||||
{
|
||||
return new ValueTask<HostedSessionContext?>((HostedSessionContext?)null);
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
|
||||
}
|
||||
}
|
||||
@@ -66,42 +66,6 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
return new FoundryAgent(aiProjectClient, innerAgent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an existing server side hosted agent as a <see cref="FoundryAgent"/> using the provided
|
||||
/// <see cref="AIProjectClient"/> and an agent-specific endpoint URI.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to use for project-level operations. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// The agent name is parsed from this URI and the active agent version is resolved server side
|
||||
/// from the endpoint's administrator-controlled version selector. Cannot be <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <param name="tools">The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <returns>A <see cref="FoundryAgent"/> instance that routes calls through the supplied agent endpoint.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
/// <remarks>
|
||||
/// Agent version selection is controlled by the Foundry administrator through the endpoint's
|
||||
/// version selector and cannot be overridden by the caller. Use the
|
||||
/// <see cref="AsAIAgent(AIProjectClient, AgentReference, IList{AITool}?, Func{IChatClient, IChatClient}?, IServiceProvider?)"/>
|
||||
/// overload when an explicit agent version pin is required.
|
||||
/// </remarks>
|
||||
public static FoundryAgent AsAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
return new FoundryAgent(aiProjectClient, agentEndpoint, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="ProjectsAgentRecord"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,9 +40,27 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> supplied to or constructed by the active constructor.
|
||||
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
|
||||
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
|
||||
/// accepted by the Foundry control plane.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
|
||||
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
|
||||
/// does not build a full <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
|
||||
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
|
||||
/// In agent-endpoint mode this is built directly from the project root derived from the
|
||||
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
|
||||
/// <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly ProjectOpenAIClient _projectOpenAIClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
@@ -76,6 +94,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
out var aiProjectClient))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +106,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectResponsesClient"/>. When supplied:
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
@@ -113,34 +134,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific
|
||||
/// endpoint while reusing an existing <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">An existing <see cref="AIProjectClient"/> rooted at the same project as <paramref name="agentEndpoint"/>.</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="aiProjectClient"/> or <paramref name="agentEndpoint"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
internal FoundryAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -150,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -182,7 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient();
|
||||
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -204,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
|
||||
{
|
||||
return this._projectOpenAIClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
@@ -289,10 +291,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a project-scoped <see cref="ProjectOpenAIClient"/> and using
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// This routes the outbound URL through the per-agent endpoint shape that the Foundry service
|
||||
/// expects for hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
|
||||
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
|
||||
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
|
||||
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
|
||||
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
@@ -305,44 +308,22 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions));
|
||||
|
||||
return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for an agent endpoint against a pre-built
|
||||
/// <see cref="AIProjectClient"/>. The caller is responsible for ensuring the supplied client
|
||||
/// is rooted at the same project as <paramref name="agentEndpoint"/>; the agent name is
|
||||
/// parsed from the endpoint URI and passed to
|
||||
/// <see cref="ProjectOpenAIClient.GetProjectResponsesClientForAgentEndpoint(string, string?, ProjectOpenAIClientOptions?)"/>.
|
||||
/// </summary>
|
||||
private static AIAgent BuildAgentEndpointInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = aiProjectClient.ProjectOpenAIClient
|
||||
.GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions)
|
||||
.AsIChatClient();
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
@@ -358,6 +339,57 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
|
||||
/// constructor by deriving the project root from the supplied agent endpoint and constructing
|
||||
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
|
||||
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
|
||||
/// conversations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
|
||||
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
|
||||
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
|
||||
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
|
||||
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
|
||||
/// user-agent policy is appended last.
|
||||
/// </remarks>
|
||||
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var projectOptions = new ProjectOpenAIClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
projectOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
|
||||
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
@@ -385,7 +417,8 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'.",
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
@@ -428,32 +461,5 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
if (clientOptions is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy pipeline behavior the caller configured on the per-agent options bag onto the
|
||||
// project-level options bag so the agent endpoint client honors it. UserAgentApplicationId
|
||||
// is project-level (not derived from the agent endpoint), so it must be carried through too.
|
||||
var projectOptions = new AIProjectClientOptions
|
||||
{
|
||||
Transport = clientOptions.Transport,
|
||||
RetryPolicy = clientOptions.RetryPolicy,
|
||||
NetworkTimeout = clientOptions.NetworkTimeout,
|
||||
MessageLoggingPolicy = clientOptions.MessageLoggingPolicy,
|
||||
UserAgentApplicationId = clientOptions.UserAgentApplicationId,
|
||||
};
|
||||
|
||||
if (clientOptions.ClientLoggingOptions is not null)
|
||||
{
|
||||
projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions;
|
||||
}
|
||||
|
||||
return projectOptions;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -13,8 +10,7 @@ namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
|
||||
/// function invocation, per-service-call chat history persistence, in-loop compaction, and a rich set
|
||||
/// of default context providers and agent decorators.
|
||||
/// function invocation, per-service-call chat history persistence, and in-loop compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -27,27 +23,6 @@ namespace Microsoft.Agents.AI;
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TodoProvider"/> — todo list management.</description></item>
|
||||
/// <item><description><see cref="AgentModeProvider"/> — agent mode tracking (plan/execute).</description></item>
|
||||
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory.</description></item>
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access.</description></item>
|
||||
/// <item><description><see cref="AgentSkillsProvider"/> — skill discovery and loading.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The agent is also wrapped with the following decorators by default (each can be disabled):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules.</description></item>
|
||||
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <see cref="HostedWebSearchTool"/> is added to the chat options by default (can be disabled via
|
||||
/// <see cref="HarnessAgentOptions.DisableWebSearch"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The underlying <see cref="ChatClientAgent"/> is configured with
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
|
||||
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
|
||||
@@ -73,9 +48,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
|
||||
- Think through the task before acting. Break complex work into clear steps.
|
||||
- Use the tools available to you to gather information, perform actions, and verify results.
|
||||
- Explain your reasoning and thought process as you work through tasks.
|
||||
- Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
|
||||
- Explain your reasoning between tool calls so the user can follow your progress.
|
||||
- If a tool call fails or returns unexpected results, adapt your approach rather than repeating the same call.
|
||||
- When you have completed the task, present a clear and concise summary of what you did and what you found.
|
||||
""";
|
||||
@@ -101,15 +74,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <exception cref="System.ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
|
||||
: base(BuildAgent(
|
||||
: base(BuildInnerAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
@@ -117,25 +90,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
{
|
||||
}
|
||||
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
{
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
if (options?.DisableToolApproval is not true)
|
||||
{
|
||||
builder.UseToolApproval();
|
||||
}
|
||||
|
||||
if (options?.DisableOpenTelemetry is not true)
|
||||
{
|
||||
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
{
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
@@ -148,28 +102,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
});
|
||||
|
||||
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
|
||||
string? agentInstructions = options?.ChatOptions?.Instructions;
|
||||
string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions;
|
||||
|
||||
string instructions = (string.IsNullOrWhiteSpace(harnessInstructions), string.IsNullOrWhiteSpace(agentInstructions)) switch
|
||||
{
|
||||
(true, true) => harnessInstructions,
|
||||
(true, false) => agentInstructions!,
|
||||
(false, true) => harnessInstructions,
|
||||
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
|
||||
};
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||
ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens);
|
||||
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy);
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
.UseFunctionInvocation()
|
||||
.UseMessageInjection()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
@@ -180,80 +121,17 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
Description = options?.Description,
|
||||
ChatOptions = chatOptions,
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
AIContextProviders = contextProviders,
|
||||
AIContextProviders = options?.AIContextProviders,
|
||||
UseProvidedChatClientAsIs = true,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||
private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens)
|
||||
{
|
||||
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
|
||||
ChatOptions result = source?.Clone() ?? new ChatOptions();
|
||||
result.Instructions = instructions;
|
||||
result.MaxOutputTokens ??= maxOutputTokens;
|
||||
|
||||
if (options?.DisableWebSearch is not true)
|
||||
{
|
||||
result.Tools ??= [];
|
||||
result.Tools.Add(new HostedWebSearchTool());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
|
||||
{
|
||||
var providers = new List<AIContextProvider>();
|
||||
|
||||
if (options?.DisableTodoProvider is not true)
|
||||
{
|
||||
providers.Add(new TodoProvider());
|
||||
}
|
||||
|
||||
if (options?.DisableAgentModeProvider is not true)
|
||||
{
|
||||
providers.Add(new AgentModeProvider(options?.AgentModeProviderOptions));
|
||||
}
|
||||
|
||||
if (options?.DisableFileMemory is not true)
|
||||
{
|
||||
AgentFileStore fileMemoryStore = options?.FileMemoryStore
|
||||
?? new FileSystemAgentFileStore(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "agent-file-memory"));
|
||||
|
||||
providers.Add(new FileMemoryProvider(
|
||||
fileMemoryStore,
|
||||
_ => new FileMemoryState
|
||||
{
|
||||
WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (options?.DisableFileAccess is not true)
|
||||
{
|
||||
AgentFileStore fileAccessStore = options?.FileAccessStore
|
||||
?? new FileSystemAgentFileStore(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "working"));
|
||||
|
||||
providers.Add(new FileAccessProvider(fileAccessStore));
|
||||
}
|
||||
|
||||
if (options?.DisableAgentSkillsProvider is not true)
|
||||
{
|
||||
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
|
||||
? new AgentSkillsProvider(source)
|
||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
|
||||
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,31 +36,13 @@ public sealed class HarnessAgentOptions
|
||||
/// Use <see cref="ChatOptions.Tools"/> to supply additional tools the agent can invoke.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="ChatOptions.Instructions"/> to provide agent-specific instructions (e.g., research methodology,
|
||||
/// data analysis workflow). These are combined with <see cref="HarnessInstructions"/> to form the final instructions
|
||||
/// sent to the model: harness instructions appear first, followed by agent-specific instructions.
|
||||
/// When <see cref="ChatOptions.Instructions"/> is <see langword="null"/>, only <see cref="HarnessInstructions"/>
|
||||
/// (or the default) is used.
|
||||
/// Use <see cref="ChatOptions.Instructions"/> to override the <see cref="HarnessAgent"/>'s built-in
|
||||
/// default instructions. When <see cref="ChatOptions.Instructions"/> is <see langword="null"/> or not set,
|
||||
/// the default instructions are used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ChatOptions? ChatOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the harness-level instructions that control general tool usage and behavior patterns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Harness instructions provide guidance on how to use tools, explain reasoning, and structure work.
|
||||
/// They are combined with <see cref="ChatOptions"/>.<see cref="ChatOptions.Instructions"/> (agent-specific instructions)
|
||||
/// to produce the final instructions sent to the model: harness instructions first, then agent-specific instructions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see langword="null"/> (the default), <see cref="HarnessAgent.DefaultInstructions"/> is used.
|
||||
/// Set to <see cref="string.Empty"/> to omit harness instructions entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? HarnessInstructions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
|
||||
/// </summary>
|
||||
@@ -79,143 +61,4 @@ public sealed class HarnessAgentOptions
|
||||
/// <see cref="ChatClientAgentOptions.AIContextProviders"/>.
|
||||
/// </remarks>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of function-invocation loop iterations per request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When set, this value is passed to <see cref="FunctionInvokingChatClient.MaximumIterationsPerRequest"/>.
|
||||
/// When <see langword="null"/>, the <see cref="FunctionInvokingChatClient"/> default is used.
|
||||
/// </remarks>
|
||||
public int? MaximumIterationsPerRequest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="ToolApprovalAgent"/> wrapper is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the agent is wrapped with tool approval middleware
|
||||
/// that supports "don't ask again" auto-approval rules.
|
||||
/// </remarks>
|
||||
public bool DisableToolApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), a <see cref="FileMemoryProvider"/> is included in the
|
||||
/// agent's context providers, using either <see cref="FileMemoryStore"/> or a default
|
||||
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/agent-file-memory/{timestamp}_{guid}</c>.
|
||||
/// </remarks>
|
||||
public bool DisableFileMemory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileMemoryProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> and <see cref="DisableFileMemory"/> is <see langword="false"/>,
|
||||
/// a default <see cref="FileSystemAgentFileStore"/> is created.
|
||||
/// This property is ignored when <see cref="DisableFileMemory"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public AgentFileStore? FileMemoryStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileAccessProvider"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), a <see cref="FileAccessProvider"/> is included in the
|
||||
/// agent's context providers, using either <see cref="FileAccessStore"/> or a default
|
||||
/// <see cref="FileSystemAgentFileStore"/> rooted at <c>{cwd}/working</c>.
|
||||
/// </remarks>
|
||||
public bool DisableFileAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom <see cref="AgentFileStore"/> for the <see cref="FileAccessProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> and <see cref="DisableFileAccess"/> is <see langword="false"/>,
|
||||
/// a default <see cref="FileSystemAgentFileStore"/> is created.
|
||||
/// This property is ignored when <see cref="DisableFileAccess"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public AgentFileStore? FileAccessStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="HostedWebSearchTool"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), a <see cref="HostedWebSearchTool"/> is added
|
||||
/// to <see cref="ChatOptions"/>.<see cref="ChatOptions.Tools"/>.
|
||||
/// </remarks>
|
||||
public bool DisableWebSearch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="TodoProvider"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), a <see cref="TodoProvider"/> is included
|
||||
/// in the agent's context providers for tracking work items.
|
||||
/// </remarks>
|
||||
public bool DisableTodoProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="AgentModeProvider"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), an <see cref="AgentModeProvider"/> is included
|
||||
/// in the agent's context providers. Use <see cref="AgentModeProviderOptions"/> to configure
|
||||
/// custom modes.
|
||||
/// </remarks>
|
||||
public bool DisableAgentModeProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom options for the <see cref="AgentModeProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, the <see cref="AgentModeProvider"/> uses its built-in default
|
||||
/// modes ("plan" and "execute"). This property is ignored when
|
||||
/// <see cref="DisableAgentModeProvider"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public AgentModeProviderOptions? AgentModeProviderOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="AgentSkillsProvider"/> is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), an <see cref="AgentSkillsProvider"/> is included
|
||||
/// in the agent's context providers. Use <see cref="AgentSkillsSource"/> to provide a custom
|
||||
/// skills source; otherwise, the provider defaults to file-based skill discovery from the current
|
||||
/// working directory.
|
||||
/// </remarks>
|
||||
public bool DisableAgentSkillsProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom <see cref="AI.AgentSkillsSource"/> for the <see cref="AgentSkillsProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> and <see cref="DisableAgentSkillsProvider"/> is <see langword="false"/>,
|
||||
/// the provider defaults to file-based skill discovery from the current working directory.
|
||||
/// This property is ignored when <see cref="DisableAgentSkillsProvider"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public AgentSkillsSource? AgentSkillsSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="OpenTelemetryAgent"/> wrapper is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the agent is wrapped with an
|
||||
/// <see cref="OpenTelemetryAgent"/> that provides OpenTelemetry instrumentation
|
||||
/// following the Semantic Conventions for Generative AI systems.
|
||||
/// </remarks>
|
||||
public bool DisableOpenTelemetry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OpenTelemetry source name used by the <see cref="OpenTelemetryAgent"/> wrapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> (the default), the framework's default source name
|
||||
/// (<c>"Experimental.Microsoft.Agents.AI"</c>) is used.
|
||||
/// Set this to a custom value to enable filtering spans from a specific <see cref="System.Diagnostics.ActivitySource"/>
|
||||
/// in your <c>TracerProvider</c> configuration.
|
||||
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public string? OpenTelemetrySourceName { get; set; }
|
||||
}
|
||||
|
||||
@@ -120,24 +120,9 @@ public static class OpenAIResponseClientExtensions
|
||||
return Throw.IfNull(responseClient)
|
||||
.AsIChatClient(model)
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(x =>
|
||||
{
|
||||
var previousFactory = x.RawRepresentationFactory;
|
||||
x.RawRepresentationFactory = state =>
|
||||
{
|
||||
var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions();
|
||||
|
||||
responseOptions.StoredOutputEnabled = false;
|
||||
|
||||
if (includeReasoningEncryptedContent &&
|
||||
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
|
||||
{
|
||||
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
|
||||
}
|
||||
|
||||
return responseOptions;
|
||||
};
|
||||
})
|
||||
.ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent
|
||||
? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } }
|
||||
: new CreateResponseOptions() { StoredOutputEnabled = false })
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
@@ -28,8 +27,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
private const string FilenameAdditionalPropertyName = "filename";
|
||||
|
||||
/// <summary>
|
||||
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
|
||||
/// to the MCP protocol <c>tools/list</c> discovery operation.
|
||||
@@ -275,46 +272,46 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
internal static AIContent ConvertContentBlock(ContentBlock block)
|
||||
{
|
||||
// Delegate to the MCP SDK's canonical converter. It maps every known
|
||||
// ContentBlock subtype (Text/Image/Audio/EmbeddedResource/ToolUse/ToolResult)
|
||||
// and sets RawRepresentation + AdditionalProperties from block.Meta.
|
||||
// It intentionally returns null for ResourceLinkBlock — map that to
|
||||
// UriContent here so callers always receive a usable AIContent.
|
||||
return block.ToAIContent() ?? block switch
|
||||
return block switch
|
||||
{
|
||||
ResourceLinkBlock link => new UriContent(link.Uri, link.MimeType ?? "application/octet-stream")
|
||||
{
|
||||
RawRepresentation = link,
|
||||
AdditionalProperties = CreateAdditionalProperties(link),
|
||||
},
|
||||
_ => new TextContent(block.ToString() ?? string.Empty)
|
||||
{
|
||||
RawRepresentation = block,
|
||||
AdditionalProperties = CreateAdditionalProperties(block),
|
||||
},
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static AdditionalPropertiesDictionary? CreateAdditionalProperties(ContentBlock block)
|
||||
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
|
||||
{
|
||||
AdditionalPropertiesDictionary? properties = null;
|
||||
|
||||
if (block.Meta is not null)
|
||||
return block.Resource switch
|
||||
{
|
||||
foreach (var property in block.Meta)
|
||||
{
|
||||
properties ??= new AdditionalPropertiesDictionary();
|
||||
properties.Add(property.Key, property.Value);
|
||||
}
|
||||
TextResourceContents text => new TextContent(text.Text),
|
||||
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static DataContent CreateDataContent(ReadOnlyMemory<byte> base64Utf8Data, string mediaType)
|
||||
{
|
||||
if (base64Utf8Data.IsEmpty)
|
||||
{
|
||||
return new DataContent($"data:{mediaType};base64,", mediaType);
|
||||
}
|
||||
|
||||
if (block is ResourceLinkBlock { Name: { Length: > 0 } name })
|
||||
#if NET8_0_OR_GREATER
|
||||
string base64 = Encoding.UTF8.GetString(base64Utf8Data.Span);
|
||||
#else
|
||||
string base64 = Encoding.UTF8.GetString(base64Utf8Data.ToArray());
|
||||
#endif
|
||||
|
||||
// If it's already a data URI, use it directly
|
||||
if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
properties ??= new AdditionalPropertiesDictionary();
|
||||
properties.TryAdd(FilenameAdditionalPropertyName, name);
|
||||
return new DataContent(base64, mediaType);
|
||||
}
|
||||
|
||||
return properties;
|
||||
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
|
||||
}
|
||||
|
||||
private static string SerializeToolsList(IEnumerable<Tool> tools)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user