Compare commits

..
Author SHA1 Message Date
Eduard van ValkenburgandCopilot 9a653af73e docs: replace stale observability migration guide with current PR's only relevant migration
The old guide documented the move away from setup_observability(otlp_endpoint=...)
which was an earlier-release API change unrelated to this PR and stale enough that
it's more confusing than helpful at this point. Replace it with a short note on the
single migration this PR introduces: callers of
enable_instrumentation(enable_sensitive_data=True) should switch to
enable_sensitive_telemetry(). Cross-link to the Disabling instrumentation section
for the rare 'force on without enabling sensitive data' use case where
enable_instrumentation() still applies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:38:17 +02:00
Eduard van ValkenburgandCopilot d1bc78108d docs: correct observability Dependencies section
- opentelemetry-sdk is no longer a hard dependency; it is lazily imported by
  create_resource(), create_metric_views(), and configure_otel_providers()
  with a clear ImportError when missing. Day-to-day instrumentation works
  with opentelemetry-api alone provided some other component configures the
  global OpenTelemetry providers (Azure Monitor, an APM agent, application
  bootstrap, etc.).
- opentelemetry-semantic-conventions-ai is no longer used anywhere in the
  source; remove it from the listed dependencies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:34:37 +02:00
Eduard van ValkenburgandCopilot c2f9544763 docs: soften disable_instrumentation() overclaim about telemetry guarantees
Replace 'no telemetry will be emitted no matter what' (which is too strong,
since callers can still pass force=True or mutate private attributes) with
language framing the disable as a user-intent contract that library and
framework code is expected to honor: the framework actively short-circuits
the public enable paths, force=True and private-attribute writes are
acknowledged as out-of-contract escape hatches that integrations should
not use on the user's behalf.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:28:15 +02:00
Eduard van ValkenburgandCopilot 6312001ecb docs: document disable_instrumentation() and force=True paths
Add a "Disabling instrumentation" section to the observability sample README
that walks through:

- The distinction between the ENABLE_INSTRUMENTATION env var (initial,
  non-sticky) and disable_instrumentation() (process-wide, sticky).
- Why the sticky semantics matter: framework integrations like
  FoundryChatClient.configure_azure_monitor() can call
  enable_instrumentation() as part of their setup, and the user's opt-out
  needs to win.
- All five surfaces guarded by the sticky disable (property reads, public
  enable functions, configure_otel_providers, direct attribute writes,
  is_user_disabled-aware integrations).
- The force=True escape hatch on both enable_instrumentation() and
  enable_sensitive_telemetry().
- How third-party integrations should consult OBSERVABILITY_SETTINGS.is_user_disabled.
- The limits of the disable (does not tear down existing providers /
  in-flight spans / third-party instrumentation, does not persist across
  processes).

Cross-links the new section from the ENABLE_INSTRUMENTATION row in the env
vars table.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:24:04 +02:00
Eduard van ValkenburgandCopilot 6a23dcd555 Add disable_instrumentation() with sticky user-intent semantics
Add a public disable_instrumentation() entry point so users can explicitly opt
out of Agent Framework telemetry, with a sticky-disable flag that makes the
user's intent "leading" — no framework code path (foundry's
configure_azure_monitor, configure_otel_providers, enable_instrumentation,
enable_sensitive_telemetry, or direct OBSERVABILITY_SETTINGS.enable_*
writes) can re-enable instrumentation until the user explicitly clears the
disable with enable_instrumentation(force=True) /
enable_sensitive_telemetry(force=True).

Also addresses the two remaining unresolved review threads on the PR:
1. test_observability_settings_defaults_instrumentation_true pins the new
   "ENABLE_INSTRUMENTATION defaults to True when env unset" behavior.
2. test_enable_instrumentation_reads_env_sensitive_data restores coverage
   for the post-import load_dotenv() fallback path.

Implementation:
- ObservabilitySettings.enable_instrumentation / enable_sensitive_data become
  properties backed by _enable_*. While _user_disabled is True, the getters
  return False and the setters drop True writes (defense in depth so third-
  party writes can't subvert the disable).
- Public is_user_disabled read-only property lets integrations (e.g. foundry's
  configure_azure_monitor) cheaply check the disable state without poking at
  privates.
- enable_instrumentation() and enable_sensitive_telemetry() short-circuit with
  an info log when disabled; gain a force=True kwarg that clears the disable.
- configure_otel_providers() still creates providers / exporters / views so a
  later force-enable can use them, but logs an info message when called while
  disabled.
- Foundry's FoundryChatClient.configure_azure_monitor and
  FoundryAgent.configure_azure_monitor early-return when the user has
  disabled, so Azure Monitor's global providers aren't installed unnecessarily.

Tests: 11 new tests covering default-on, env re-read at call time, sticky
behavior against each re-enable surface (enable_instrumentation,
enable_sensitive_telemetry, configure_otel_providers, direct attribute
writes), force=True override, re-arming the disable, and the __all__ export.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 09:30:41 +02:00
Tao Chen 1d1e58e12a Fix mypy 2026-05-15 10:41:18 -07:00
Tao Chen 5e5c6976f1 Formatting 2026-05-14 17:13:33 -07:00
Tao Chen 94ce49dcf5 Merge branch 'main' into local-branch-python-enable-observability-by-default 2026-05-14 17:06:28 -07:00
Tao Chen 9007b3262a Add warning 2026-05-14 17:05:59 -07:00
Tao Chen f5f0d828ab Revert uv.lock 2026-05-14 17:01:58 -07:00
Tao Chen 13b8e68503 Address Copilot comments 2026-05-14 16:56:29 -07:00
Tao Chen 07ea764469 Optimization when span is not recording 2026-05-14 16:27:58 -07:00
Tao Chen 4a83d92c96 Update samples 2026-05-14 15:02:15 -07:00
Tao Chen 0219e17be2 Enable instrumentation by default 2026-05-14 13:42:00 -07:00
293 changed files with 4057 additions and 13638 deletions
+2 -8
View File
@@ -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
+5 -3
View File
@@ -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.
+3 -4
View File
@@ -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" />
+1 -8
View File
@@ -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)
@@ -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; } = [];
}
@@ -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 &lt;filename&gt;</c> and <c>/session-import &lt;filename&gt;</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);
}
}
}
@@ -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);
}
@@ -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);
@@ -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");
@@ -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);
@@ -56,7 +56,7 @@ public abstract class ToolCallFormatter
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new BackgroundAgentToolFormatter(),
new SubAgentToolFormatter(),
new FileMemoryToolFormatter(),
new WebSearchToolFormatter(),
new FallbackToolFormatter(),
@@ -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>
@@ -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),
});
@@ -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):");
@@ -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):");
@@ -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.
@@ -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 |
| --- | --- |
@@ -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 |
@@ -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!")
```
@@ -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
```
+1 -1
View File
@@ -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 |
@@ -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
@@ -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();
@@ -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
@@ -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));
}
}
@@ -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();
@@ -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
@@ -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));
}
}
@@ -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
@@ -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));
}
}
@@ -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));
}
}
@@ -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"]
@@ -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"]
@@ -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 |
@@ -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
@@ -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,6 +1,7 @@
.env
bin/
obj/
out/
.vs/
.vscode/
*.user
@@ -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
@@ -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));
}
}
@@ -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));
}
}
@@ -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,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" />
@@ -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));
}
}
@@ -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>
@@ -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));
}
}
@@ -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!));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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>
@@ -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();
@@ -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
```
@@ -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.");
}
@@ -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;
@@ -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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/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