Compare commits

...
Author SHA1 Message Date
Peter Ibekwe d83bc07582 Fix CI failures. 2026-06-11 10:51:06 -07:00
Peter Ibekwe 4b0aeb76a5 Address PR comments. 2026-06-11 10:35:34 -07:00
Peter Ibekwe c0ea099bd8 Address PR comments 2026-06-10 17:43:42 -07:00
Peter Ibekwe 3498f9dc66 Remove unnecessary comment 2026-06-10 15:43:36 -07:00
Peter Ibekwe 564259a4aa Fix declarative object parsing bug 2026-06-10 14:13:52 -07:00
Peter IbekweGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3753d938f5 .NET: Bug fixes for declarative workflows (#6427)
* declarative workflow approval flow fix

* Update mcp handler cache construction

* fix method argument.

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Fix identation

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-10 18:08:32 +00:00
60cc5ee4e4 .NET: Make GitHub.Copilot.SDK build targets reach transitive consumers (#6455) (#6457)
* .NET: Make GitHub.Copilot.SDK build targets reach transitive consumers (#6455)

Microsoft.Agents.AI.GitHub.Copilot now ships a buildTransitive/ bridge so
consumers who only reference this package (the normal use case) get the
GitHub.Copilot.SDK's CLI binary-download MSBuild targets executed at build
time. Without this, the SDK shipped its targets under build/ which NuGet
only auto-imports for projects with a direct PackageReference to the SDK,
so consumers of the adapter package got only the managed .dll, no
copilot.exe in their output, and a runtime InvalidOperationException on
the first RunAsync.

The bridge consists of two files under buildTransitive/:

* Microsoft.Agents.AI.GitHub.Copilot.props is generated at this package's
  pack time and pins the SDK version (from PackageVersion items in
  Directory.Packages.props) into _MicrosoftAgentsAICopilotSdkVersion.

* Microsoft.Agents.AI.GitHub.Copilot.targets is static and imports the
  SDK's own build/GitHub.Copilot.SDK.targets from the NuGet cache using
  the pinned version. The version-pin condition no-ops gracefully if the
  resolved SDK differs from what was baked in (e.g. consumer overrides
  the SDK version directly), so this is purely additive.

Verified by packing locally, restoring from a flat local feed, and
building a transitive-only consumer (PackageReference to MAF only, no
direct SDK ref). copilot.exe lands at bin/{cfg}/{tfm}/runtimes/{rid}/
native/copilot.exe as expected, matching the path the SDK's runtime
CopilotClient looks at.

Fixes #6455

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address Copilot review feedback (#6457)

- buildTransitive/.targets: compute the full SDK targets path with a single
  Path.Combine call into one property (_MicrosoftAgentsAICopilotSdkTargetsPath),
  used in both Project= and Exists() — no more split between Path.Combine for
  the directory and inline / separator for the file name.

- Split the version-defaulting Condition between the two files: the generated
  .props now just bakes the packaged SDK version into a dedicated property
  (_MicrosoftAgentsAICopilotSdkPackagedVersion), and the static .targets file
  is the single place that defaults _MicrosoftAgentsAICopilotSdkVersion to it.
  Removes the need for any MSBuild escape gymnastics in the pack-time string
  construction, and keeps the consumer override path the same.

- _GenerateBuildTransitiveProps now hangs off public BeforeTargets (Build, Pack)
  in addition to _GetPackageFiles, so the file is generated even without a
  full pack, and we're not solely dependent on an underscore-prefixed internal
  target. The <None Pack=true /> items live in a top-level ItemGroup so they
  are collected at evaluation time instead of being added from inside the
  Target.

End-to-end retested with a transitive-only consumer (PackageReference to MAF
only, no direct GitHub.Copilot.SDK ref): copilot.exe lands at
bin/Debug/net10.0/runtimes/win-x64/native/copilot.exe (141.8 MB) as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Tamir Dresher <tamirdresher@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 18:07:18 +00:00
dd29f9aa65 .NET: Hosted Agent Sample - Toolbox with various Auth (#5777) (#6018)
* .NET: Add Hosted-Toolbox-AuthPaths sample and auto-map /readiness with toolbox health gating (#5777)

Add a new hosted agent sample demonstrating five MCP tool authentication paths
(API key, agent MI, project MI, custom OAuth, literal token) via a Foundry Toolbox.

Package changes (Microsoft.Agents.AI.Foundry.Hosting):
- MapFoundryResponses now auto-maps GET /readiness via MapHealthChecks, idempotent
  across Tier 1/2 (AgentHost, already mapped) and Tier 3 (WebApplication, gap filled).
- AddFoundryResponses registers AddHealthChecks() so the pipeline is available.
- AddFoundryToolboxes registers FoundryToolboxHealthCheck on the /readiness aggregate,
  gating readiness on pre-registered toolbox startup outcome (per spec section 3.1).
- FoundryToolboxService now exposes StartupStatus and FailedToolboxNames properties.

New types:
- FoundryToolboxStartupStatus (public enum): Pending, Healthy, Failed, NoEndpoint.
- FoundryToolboxHealthCheck (internal IHealthCheck): adapts startup status to the
  AspNetCore HealthChecks pipeline with failed toolbox names in result data.

Tests:
- 3 new tests for /readiness auto-mapping (Tier 3 default, pre-mapped skip, idempotent).
- 4 new tests for FoundryToolboxHealthCheck (Pending, NoEndpoint, Failed, Healthy).
- 3 enhanced FoundryToolboxServiceTests with StartupStatus assertions.

* .NET: Align FoundryToolboxService with tools-integration-spec (#5777 Part A)

Bring Microsoft.Agents.AI.Foundry.Hosting's toolbox path into compliance with
tools-integration-spec.md sections 2-4, 6.3, and 9. Empirically validated
against tao-foundry-prj: the previous code (reading FOUNDRY_AGENT_TOOLSET_ENDPOINT,
which the platform never injects) silently registered zero tools in production.

Package changes (Microsoft.Agents.AI.Foundry.Hosting):

- FoundryToolboxService.StartAsync now derives the toolbox proxy base URL from
  the platform-injected FOUNDRY_PROJECT_ENDPOINT and constructs the per-toolbox
  URL as {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{name}/mcp?api-version={ApiVersion}
  per spec sections 2-3. The legacy FOUNDRY_AGENT_TOOLSET_ENDPOINT env var is
  removed outright (preview package, no production consumers).
- FoundryToolboxOptions.ApiVersion default flipped to 'v1' to match spec example.
- FoundryToolboxBearerTokenHandler always sends the mandatory
  Foundry-Features: Toolboxes=V1Preview header per spec section 2, merging any
  additional flags supplied via the FOUNDRY_AGENT_TOOLSET_FEATURES env var.
- FoundryToolboxBearerTokenHandler token scope changed from
  https://cognitiveservices.azure.com/.default to https://ai.azure.com/.default
  per spec section 4.
- FoundryToolboxBearerTokenHandler propagates W3C trace context (traceparent,
  tracestate, baggage) from Activity.Current per spec section 6.3.

Sample changes:

- Hosted-Toolbox-AuthPaths and Hosted-Toolbox Program.cs, README.md, and
  .env.example corrected to describe the actual env-var contract
  (FOUNDRY_PROJECT_ENDPOINT auto-injected; AZURE_AI_PROJECT_ENDPOINT as the
  local-dev fallback). Removes the misleading 'auto-injected by Foundry runtime'
  claims for FOUNDRY_AGENT_TOOLSET_ENDPOINT.
- Hosted-Toolbox-AuthPaths/agent.manifest.yaml declares the toolbox and model
  dependencies under resources[] per the AgentManifest schema so azd ai agent
  init users get them provisioned automatically.

Tests:

- 4 new FoundryToolboxServiceTests covering env-var derivation, EndpointOverride
  precedence, trailing-slash normalization, and the existing NoEndpoint behavior
  under the new env var name.
- 4 new FoundryToolboxBearerTokenHandlerTests covering token scope, mandatory
  feature header always present, header merging with override, no duplicate
  mandatory flag, trace context propagation from Activity.Current, and no
  override of caller-set traceparent.
- New FoundryProjectEndpointEnvFixture xUnit collection definition serializes
  env-var-mutating tests across FoundryToolboxServiceTests and
  FoundryToolboxHealthCheckTests, preventing parallel-execution races.
- FoundryToolboxHealthCheckTests adjusted for the new env var name.

* .NET: Drop ACA prereq from Hosted-Toolbox-AuthPaths README (#5777 Part B)

Empirically verified that any Azure Cognitive Services MCP endpoint already in
the Foundry project (e.g., a Language service MCP) accepts Entra tokens and can
serve Paths 2 and 3 without deploying a separate Azure MCP Server to ACA.

README updates:
- Step 0 rewritten: 'Identify an Entra-authenticated MCP target in your project'
  instead of 'Deploy Azure MCP Server to Azure Container Apps' (the original
  azmcp-foundry-aca-mi setup is now optional, not required).
- Auth-paths matrix updated to describe AAD-based connections targeting a
  Cognitive Services MCP URL (e.g., Language service) instead of an ACA URL.
- Step 2 connections table updated: the Entra ID category is now a single 'AAD'
  authType. The original 'Agent Identity' vs 'Project Managed Identity' as
  selectable connection sub-types is NOT exposed via the ARM control plane
  today; the platform selects the calling principal contextually. Both
  connections in the walkthrough share the same shape and target.
- Added an explicit RBAC note: the agent identity AND project MI must hold the
  required role (typically Cognitive Services User) on the target resource;
  without it the MCP server returns HTTP 401 even though the connection wiring
  is correct.
- Toolbox tool entries renamed lang_entra_agent / lang_entra_project to
  match the new connection names.

Empirical validation supporting these changes is captured in the session
plan.md (Part B addendum).

* .NET: Document correct connection shape for Hosted-Toolbox-AuthPaths Paths 2/3 (#5777)

Updates the sample README with the verified connection shape and RBAC procedure
for Microsoft Entra agent-identity and project-managed-identity MCP authentication:

- Connection authType values: AgenticIdentityToken (agent identity) and
  ProjectManagedIdentity (project MI), both with category=RemoteTool.
- Top-level audience property required; for Cognitive Services targets the value
  is https://cognitiveservices.azure.com.
- Connections created via ARM REST (the Foundry portal wizard does not yet
  expose these authTypes).
- RBAC grants target the project's shared agent identity blueprint principal
  (project.properties.agentIdentity.agentIdentityId) for Path 2 and the
  project's system-assigned MI (project.identity.principalId) for Path 3.
- Troubleshooting table updated with the audience-mismatch symptom and the
  startup-cache behavior of FoundryToolboxService.

* .NET: Drop Path 3 (project MI) and align with new agent model in Hosted-Toolbox-AuthPaths (#5777)

Updates the sample to use only the new Foundry agent object model and removes
the project managed identity path:

- Auth-path matrix reduced to four paths: key, Entra agent identity, custom
  OAuth, inline authorization. Project managed identity is moved into a note
  describing when it applies (multiple agents sharing access) rather than as
  a documented sample path.
- RBAC instructions reference the agent's own instance_identity.principal_id
  from the agent ARM resource (new agent object model) instead of the
  project's shared agent identity blueprint (legacy model).
- Step 2 (connections) creates only the AgenticIdentityToken connection.
- Step 3 (toolbox tools) lists four tool entries instead of five.
- Sample prompts and troubleshooting table updated to match.

* .NET: Restore Path 3 (project MI) to Hosted-Toolbox-AuthPaths matrix (#5777)

The sample's purpose is to enumerate every authentication path a Foundry toolbox
can drive, not to pick one. Path 3 belongs alongside the other four with
explicit guidance for when each path is the right choice.

- Path 3 (project managed identity, authType=ProjectManagedIdentity) restored
  to the matrix with a 'When to pick this' column.
- Step 2 (connections) provisions both lang-mcp-agent-id and lang-mcp-project-mi
  via ARM REST.
- Step 3 (toolbox) lists five tool entries (one per path).
- RBAC instructions cover both the agent's instance identity (Path 2) and the
  project's system-assigned MI (Path 3).
- Sample prompts include all five paths.
- Troubleshooting table updated accordingly.

* .NET: Fix duplicate line in Hosted-Toolbox-AuthPaths README (#5777)

* .NET: Fix broken markdown link to ToolCallingApprovalHostedAgentFixture (#5777)

* .NET: Fix relative path depth in markdown link (#5777)

* .NET: Address Copilot review feedback for #5777

- FoundryToolboxHealthCheck description: rename FOUNDRY_AGENT_TOOLSET_ENDPOINT
  → FOUNDRY_PROJECT_ENDPOINT (stale reference; operator-facing in /readiness body).
- FoundryToolboxStartupStatus.NoEndpoint XML doc: same rename.
- ServiceCollectionExtensions XML docs: same rename + URL shape update.
- Foundry.Hosting.IntegrationTests.TestContainer: remove explicit
  app.MapGet('/readiness') — now redundant + would conflict with the
  auto-mapped readiness route from MapFoundryResponses.
- Hosted-Toolbox-AuthPaths agent.manifest.yaml: parameterize TOOLBOX_NAME via
  {{TOOLBOX_NAME}} template substitution and declare it under parameters with a
  default of 'auth-paths-toolbox' so the README's 'use any name' guidance
  actually works for hosted deployments.

* .NET: Address Copilot review round 2 — fallback env + dedup + naming (#5777)

- FoundryToolboxService.StartAsync: fall back to AZURE_AI_PROJECT_ENDPOINT when
  FOUNDRY_PROJECT_ENDPOINT is absent. Matches the local-dev convention used by
  the samples and resolves the doc/code mismatch flagged in review.
- FoundryToolboxHealthCheck description updated for the fallback.
- AddFoundryToolboxes: guard against duplicate health-check registration via an
  explicit name-uniqueness check on HealthCheckServiceOptions.Registrations.
  AddCheck<T>(name, ...) does not dedupe by name, so repeated AddFoundryToolboxes
  calls would have registered multiple instances.
- FoundryToolboxOptions.EndpointOverride doc: clarify URL becomes
  {EndpointOverride}/toolboxes/{name}/mcp (was missing /toolboxes/ segment).
- Hosted-Toolbox sample (Program.cs + README): switch FOUNDRY_TOOLBOX_NAME to
  TOOLBOX_NAME (the FOUNDRY_* prefix is reserved by the platform), default
  changed from 'my-toolset' to 'my-toolbox', terminology updated from 'Toolset'
  to 'Toolbox'.
- FoundryToolboxServiceTests: 2 test renames to reflect what they actually
  assert (StartupStatus + FailedToolboxNames, not URL shape directly).
- Tests adjusted to clear both env vars in NoEndpoint scenarios.

* .NET: Fix stale NoEndpoint XML doc and misleading test comment (#5777)

Update FoundryToolboxStartupStatus.NoEndpoint XML doc to mention both
FOUNDRY_PROJECT_ENDPOINT and AZURE_AI_PROJECT_ENDPOINT (the service
checks both since the fallback was added).

Fix test comment that claimed URL derivation validation when the test
only asserts on StartupStatus and FailedToolboxNames.

* Remove OAuth consent path from AuthPaths sample, keep four working auth paths

The interactive OAuth identity passthrough path needs a protocol gap closed in the
hosting package (the proprietary oauth_consent_request item is not representable
through the OpenAI/MEAI abstractions), so it is deferred to a separate spike branch.

This strips the OAuth path from the AuthPaths sample, the companion REPL client, the
agent manifest, and the docs, then renumbers the inline Authorization path so the
sample teaches four contiguous paths: API key via connection, Entra agent identity,
Entra project managed identity, and inline Authorization (anti-pattern).

Package code is unchanged; the consent infrastructure already present in main stays
as baseline. Both samples build with --warnaserror and all 246 hosting unit tests pass.

* .NET: Drop project MI auth path and dedicated client from Hosted-Toolbox-AuthPaths (#5777)

Live validation against tao-foundry-prj showed the ProjectManagedIdentity
path failing with an unresolved token audience 401, so the sample now ships
three working auth paths instead of four: connection key, agent managed
identity, and inline Authorization.

Changes:
- Remove the project managed identity path from the AuthPaths sample matrix,
  prerequisites, connections, toolbox table, prompts, Program.cs instructions
  and agent.manifest.yaml.
- Delete the near duplicate Hosted-Toolbox-AuthPaths-Client project and remove
  it from the solution. The README now drives the agent with the shared
  SimpleAgent REPL via AsAIAgent(agentEndpoint).
- Correct the troubleshooting note: the Foundry toolbox tools/list is all or
  nothing, so one bad source returns -32007, fails startup, and returns 424
  for every path. Add the allowed_tools caveat that names must match the
  upstream server.
- Mark the toolbox startup status and health check experimental under
  AgentsAIExperiments (MAAI001) instead of AIOpenAIResponses, and update the
  package NoWarn set accordingly.

* .NET: Address PR review nits for Hosted-Toolbox-AuthPaths (#5777)

- Remove duplicated NU1903 comment in Foundry.Hosting csproj.

- Fix stale 'four-tool' cross-links in Hosted-Toolbox and Hosted-McpTools READMEs to describe the three-path toolbox driven by the shared SimpleAgent REPL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Address toolbox startup-status review feedback (#5777)

- Rename FoundryToolboxStartupStatus.Failed to Unhealthy so it is the proper opposite of Healthy, and clarify the doc comment covers the partial-failure case.

- Raise the missing-endpoint toolbox log from Information to Warning, since enabling toolboxes is an explicit opt-in and a silently disabled toolbox warrants a higher-severity signal.

- Update unit tests and the AuthPaths README troubleshooting row accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Reword toolbox-wiring comment to avoid hosting-layer internals (#5777)

Address PR review feedback: explain how a Foundry Toolbox is attached using the public API (AddFoundryToolboxes vs the CreateHostedMcpToolbox marker) and observable behavior, instead of naming the internal AgentFrameworkResponseHandler type and FoundryToolboxService.Tools property.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 16:49:48 +00:00
a5f4e0078e .NET: Fix .NET Copilot integration tests for SDK v1.0.0 (#6424)
* Fix .NET Copilot integration tests for SDK v1.0.0

- Remove hard-skip in favor of runtime Assert.Skip when COPILOT_GITHUB_TOKEN is not set
- Add [Trait("Category", "Integration")] for CI filtering
- Fix FunctionTool test: use explicit SessionConfig with Tools, OnPermissionRequest, and SystemMessage
- Mark RemoteMcp test as IntegrationDisabled (requires OAuth flow)
- Create explicit sessions in all tests and delete after each (cleanup)
- Remove unused System.Diagnostics import
- Simplify SkipIfCopilotNotConfigured to only check env var

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: use try/finally for session cleanup, IsNullOrWhiteSpace

- Wrap act/assert in try/finally so sessions are always deleted even on failure
- Use IsNullOrWhiteSpace instead of IsNullOrEmpty for token check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add COPILOT_GITHUB_TOKEN to .NET integration test workflow

The Copilot SDK runtime reads this env var directly for authentication.
No Node.js/npm install needed - the SDK downloads the CLI binary at build time.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 15:41:48 +00:00
westeyandGitHub 3c0c12cd46 .NET: Update release version for 2026-06-10 release and switch GH.CP Agent to RC (#6454)
* Update release version for 2026-06-10 release

* Switch GitHub.Copilot to RC
2026-06-10 15:26:23 +00:00
8dde9ef627 Python: HarnessAgent: Disable compaction when max tokens not provided (#6410)
* HarnessAgent: Disable compaction when max tokens not provided

* Fix regression.

* Address PR comments

* Require max_output_tokens to be positive

Reject max_output_tokens=0 (must be positive), mirroring
max_context_window_tokens. Addresses PR review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 13:57:23 +00:00
93cbf6b3f0 Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None (#6421)
* Parse structuredContent from MCP CallToolResult (#3313)

The _parse_tool_result_from_mcp method only iterated over the content
field from CallToolResult, ignoring the structuredContent field entirely.
MCP servers that return JSON data via structuredContent (e.g., Power BI
MCP) appeared to return None.

Add handling for structuredContent: when present, serialize it as JSON
text and append it to the result list. This preserves the data for the
LLM while maintaining backward compatibility with existing behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Parse MCP CallToolResult.structuredContent field to prevent tool results returning None

Fixes #3313

* Address review feedback: add default=str to json.dumps and remove .checkpoints/

- Add default=str to json.dumps for structuredContent serialization so
  non-JSON-serializable values (e.g. bytes) degrade gracefully instead
  of raising TypeError
- Remove all .checkpoints/ runtime artifacts from the repository
- Add **/.checkpoints/ to .gitignore to prevent future accidental commits
- Add test for non-serializable structuredContent values

Fixes #3313

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #3313: Python: MCP CallToolResult.structuredContent field is not parsed, causing tool results to return None

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 12:51:09 +00:00
9a56bc9f16 Python: [BREAKING] Add sampling guardrails to MCP tools (#6413)
* Add sampling guardrails to MCP tools

Add approval, token, and request-count controls to the MCP sampling
callback used when an MCPTool is configured with a chat client.

- Add `sampling_approval_callback`, `sampling_max_tokens`, and
  `sampling_max_requests` parameters to `MCPTool` and its
  `MCPStdioTool`, `MCPStreamableHTTPTool`, and `MCPWebsocketTool`
  subclasses, positioned directly after `client`.
- Gate each server-initiated `sampling/createMessage` request behind the
  approval callback, which denies by default when no callback is provided.
- Clamp the requested `maxTokens` to `sampling_max_tokens` and enforce a
  per-session request count via `sampling_max_requests`.
- Log incoming sampling requests at WARNING level (counts only).
- Export `SamplingApprovalCallback` from the public API.
- Add tests, a sample, and documentation updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make sampling denial message context-aware

Distinguish the deny-by-default case (no approval callback configured)
from an explicit denial by a configured `sampling_approval_callback`, so
the returned ErrorData message is accurate for callback-driven denials
and exceptions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 10:17:36 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Roger Barreto
cea83bd8d5 .NET: Bump Microsoft.Extensions.AI packages to 10.6.0, align transitive dependency floor, and update Merge Gatekeeper ignores (#6148)
* Bump Microsoft.Extensions.AI packages to 10.6.0

* Align transitive package versions for Microsoft.Extensions.AI 10.6.0

* Ignore external review check in Merge Gatekeeper

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-06-10 10:02:22 +00:00
7ae73a68d6 Remove broken Atomic Agents docs link (#6442)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 09:07:51 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
3daed114ee Python: bump package versions for 1.8.1 release (#6420)
* Python: bump package versions for 1.8.1 release

* Python: bump agent-framework-foundry-hosting for 1.8.1 release

* Python: bump ag-ui and azurefunctions for 1.8.1 release

* Remove incorrect agent-framework-foundry changelog entry for #6259

* Add [1.8.1] changelog compare link and update [Unreleased] base

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 21:27:42 +00:00
5e097276a0 .NET: Add Foundry Deployment docs to HA sample READMEs (#6365)
* Add 'Deploying to Foundry (azd spec)' sections to all Foundry hosted agent samples

This commit adds comprehensive deployment documentation to all 13 .NET Foundry hosted agent samples that were missing it. Each sample now includes:

- Instructions to initialize an azd project from the sample's agent.manifest.yaml
- Steps to deploy using 'azd deploy'
- Example environment variable overrides for customization
- Link to the official Foundry deployment guide

Samples updated:
- Hosted-LocalTools
- Hosted-Files
- Hosted-FoundryAgent
- Hosted-McpTools
- Hosted-Observability
- Hosted-MemoryAgent
- Hosted-TextRag
- Hosted-ToolboxMcpSkills
- Hosted-AzureSearchRag
- Hosted-AgentSkills
- Hosted-Workflow-Handoff
- Hosted-Workflow-Simple
- Hosted-Invocations-EchoAgent

Each section includes the correct agent name from the sample's manifest and points to the correct GitHub URL for initializing the azd project.

Fixes: https://github.com/microsoft/agent-framework/issues/6308

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* docs(samples): fix Foundry hosted README consistency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(samples): address PR 6365 README review comments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 19:40:36 +00:00
383d551b86 Purview: Parallelize PSPC cold-cache scope refresh (#5832)
* Parallelize Purview PSPC cold cache path

* Cache Purview payment-required state for scope refresh

* Cache Purview payment-required state for scope refresh

* Align Purview policy action dedupe and 402 caching

 Deduplicate combined policy actions by action and restriction action so restriction-only actions are preserved
without duplicating identical entries. Cache tenant-level payment-required state from background scope refresh so
subsequent calls short-circuit consistently.

* .NET: Implement best-effort caching for background job scope retrieval and add unit tests for cache write failures

* Purview - feat: Enhance ScopedContentProcessor to queue ContentActivityJob when no applicable scopes are found and update related tests

* docs: Update purview package README and AGENTS documentation to reflect caching optimizations and policy enforcement scenarios

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 18:01:21 +00:00
Hasan GhomiandGitHub 2a345e5d3b .NET: Fix Magentic to share agent replies across team (#6222)
* Fix Magentic to share agent replies across team

The per-round instruction was sent untargeted (fan-out delivered it to
every participant) and replies were never relayed, so a later speaker saw
the prior speaker's instruction but not its response - inverted from
GroupChatHost and the Python reference.

- Target the instruction at the selected speaker only.
- Broadcast each reply to the other participants (buffered, no TurnToken),
  excluding the responder via _currentSpeakerExecutorId, mirroring
  GroupChatHost.
- Persist _currentSpeakerExecutorId across checkpoints.
- Add a regression test.

* Address review feedback: null-guard, explicit checkpoint key, drop vacuous assertion

* Address review feedback: centralize checkpoint keys, clear current speaker

- Move CurrentSpeakerStateKey into MagenticConstants as
  nameof(CurrentSpeakerStateKey)
- Clear _currentSpeakerExecutorId in ResetAndReplanAsync and
  PrepareFinalAnswerAsync so a checkpoint taken in those windows does not
  persist a stale speaker
- Add UTF-8 BOM to RecordingEchoAgent.cs to satisfy the format check.
2026-06-09 17:00:42 +00:00
632f67b92e Python: [Generated by SRE Agent] docs: clarify checkpoint storage security model and deserialization trust boundaries (#6295)
* docs: clarify checkpoint storage security model and deserialization trust boundaries

Add Security Model documentation sections to the checkpoint encoding and
Azure Functions serialization modules explaining:
- Checkpoint storage is a trusted data source requiring access controls
- The RestrictedUnpickler allowlist is defense-in-depth, not a security boundary
- Developer responsibilities for securing storage backends
- Guidance on using allowed_types and strip_pickle_markers

Co-authored-by: Azure SRE Agent <noreply@microsoft.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Azure SRE Agent <noreply@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 16:53:48 +00:00
Shawn HenryandGitHub 5e6eb6f121 New logo in banner (#6380) 2026-06-09 16:41:28 +00:00
Shawn HenryandGitHub dbfacbfc4a New Microsoft Agent Framework logos (#6378) 2026-06-09 15:56:56 +00:00
29cec0d27b Python: fix: use getattr for non-OpenAI provider response compatibility (#6270)
* fix: use getattr for non-OpenAI provider response compatibility

Fixes #6234
Fixes #6235

Use getattr with None fallback for system_fingerprint and output
attributes to prevent AttributeError when non-OpenAI providers
return response objects without these fields.

* fix: use typed variable for response output to satisfy pyright

Fixes #6235

Use getattr with None fallback for the output attribute, and assign
to a typed list variable before the match statement to help pyright
narrow the response item types correctly.

* fix: rename response_outputs to avoid name collision with case-block variable

Fixes #6235

Rename outputs to response_outputs on line 1974 to avoid mypy error
about conflicting variable names in the match statement's case blocks.
Also use list[Any] for explicit generic type annotation.

* fix: use cast(list[Any]) for response output to satisfy pyright

Fixes #6235

The getattr() call returns Unknown type which pyright cannot narrow
in the match statement. Use an explicit cast to list[Any].

* fix: use hasattr guard instead of getattr for response.output

Fixes #6235

Using hasattr(response, 'output') and then accessing response.output
directly gives pyright enough type information to verify the match
statement exhaustiveness. This avoids the cast(list[Any]) approach
which pyright still flagged as partially unknown.

* fix: use ternary operator for response_outputs assignment

Replace if-else block with ternary expression to satisfy ruff SIM108 lint rule.
This fixes the Package Checks (3.11) CI failure.

* fix: use ternary with cast for ruff SIM108 and pyright type safety

Replace if-else block with ternary expression using cast(list[Any], ...)
to satisfy:
- ruff SIM108 (use ternary instead of if-else)
- ruff E501 (line length < 120)
- pyright type narrowing (cast preserves type info lost in ternary)

All local checks pass: ruff check, ruff format, pyright, 298 tests.

* fix: replace hasattr+cast with try/except to preserve pyright types

---------

Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-06-09 15:17:39 +00:00
96d242fa7f .NET: Remove required token params from HarnessAgent, make compaction opt-in (#6409)
* Move token params from HarnessAgent constructor to options

Remove the required maxContextWindowTokens and maxOutputTokens
constructor parameters from HarnessAgent and AsHarnessAgent, replacing
them with optional MaxContextWindowTokens and MaxOutputTokens properties
on HarnessAgentOptions.

When both values are provided, compaction is enabled as before (in-loop
CompactionProvider and chat reducer on the default InMemoryChatHistory
Provider). When either is null, compaction is disabled entirely, making
it opt-in.

New constructor: HarnessAgent(IChatClient, HarnessAgentOptions?,
ILoggerFactory?, IServiceProvider?)

Closes #6333

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Improving comments.

* feat: Add custom CompactionStrategy and DisableCompaction to HarnessAgentOptions

Allow users to provide their own CompactionStrategy via options, with
a clear priority system:
1. DisableCompaction=true: no compaction regardless of other settings
2. Custom CompactionStrategy provided: use it (token params ignored)
3. Both MaxContextWindowTokens and MaxOutputTokens set: default strategy
4. Otherwise: no compaction

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: Address PR review comments on compaction opt-in

- Update chatClient param XML doc to reflect compaction is opt-in
- Strengthen compaction tests to assert ChatReducer is null/not-null
  rather than just asserting construction succeeds

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 13:06:00 +00:00
9486c76ef8 .NET: Add Reasoning to ChatClientAgent ChatOptions merging (#5463)
* Add reasoning option to request chat options in ChatClientAgent

* Add tests for ChatOptions reasoning merging in ChatClientAgent

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-06-09 11:25:31 +00:00
caa75f7cdd Python: Add Foundry Toolbox MCP skills hosted agent sample (#6363)
* Add 12_foundry_toolbox_mcp_skills hosted agent sample

Demonstrates using MCPSkillsSource with a Foundry Toolbox MCP endpoint
to discover and serve skills via SkillsProvider (progressive disclosure).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix env var reference in README and reuse local var in main.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Require AZURE_AI_MODEL_DEPLOYMENT_NAME and use placeholder in .env.example

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document Toolbox MCP skills vs Foundry Skills in sample README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reference 12_foundry_toolbox_mcp_skills in parent README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 08:38:19 +00:00
cfb033e5d4 Python: Filter MCP tool kwargs to declared params via allowlist (#6399)
* Filter MCP tool kwargs to declared params via allowlist

Previously MCPTool combined framework runtime kwargs (from
FunctionInvocationContext.kwargs) with the LLM-supplied arguments and
stripped only a hardcoded denylist of known framework keys before
forwarding to the MCP server. Any new framework-injected kwarg leaked to
the server unless the denylist was updated.

Switch to an allowlist built from each tool's declared parameters
(inputSchema.properties). Only declared params are forwarded; everything
else is stripped. Add an `additional_tool_argument_names` constructor
argument so users can opt extra names back in, globally (Sequence[str])
and/or per remote tool name (Mapping with reserved "*" global key). The
existing denylist is kept as a safety net for framework-named params a
server declares in its schema; explicitly opted-in extras always win. The
reserved _meta handling is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address MCP allowlist review comments and fix reload arg loss

- Fix pyright reportUnknownArgumentType in _load_tools (cast schema properties).
- Register declared param names before the existing-tool skip guard so that
  tool-list reloads preserve the allowlist for already-loaded tools (previously
  unchanged tools silently dropped all declared args after a background reload).
- Handle bare-string values in an additional_tool_argument_names mapping instead
  of iterating their characters.
- Clarify the framework denylist comment: explicit extras override the denylist.
- Make the extras-override-denylist test unambiguous (opt in a denylisted name).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 07:37:11 +00:00
Yufeng HeandGitHub d222079df9 .NET: fix: preserve AG-UI session history (#5904)
* fix: preserve AG-UI session history

* refactor: use static AG-UI provider check
2026-06-09 07:06:13 +00:00
e89e745bc0 Python: feat(claude): bump claude-agent-sdk to 0.2.87 (#6248)
* feat(claude): bump claude-agent-sdk to 0.2.87

Upgrade claude-agent-sdk dependency from >=0.1.36,<0.1.49 to >=0.2.87,<0.3.

Changes:
- Bump version pin in pyproject.toml
- Add 'xhigh' effort level to ClaudeAgentOptions (Opus 4.7 specific)
- Expose new upstream SDK options: skills, session_id, task_budget,
  include_hook_events, strict_mcp_config, continue_conversation,
  fork_session
- Add TaskBudget type import
- Update uv.lock

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: lower claude-agent-sdk floor to >=0.1.36

Keep the lower bound at 0.1.36 since the 0.1→0.2 transition was additive
and our code works on older versions as long as new options aren't used.
This avoids forcing unnecessary upgrades on existing users.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: replace TaskBudget import with inline type for SDK compat

TaskBudget was added in claude-agent-sdk 0.2.93 but does not exist in
0.2.87. Use dict[str, int] inline type instead so type checking passes
against 0.2.87. Lock file pinned to 0.2.87.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 06:01:55 +00:00
142 changed files with 6442 additions and 924 deletions
@@ -88,6 +88,7 @@ jobs:
env:
COSMOSDB_ENDPOINT: https://localhost:8081
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
# They are outside our control and their transient failures should not block merges.
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review"
with:
script: |
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
+1
View File
@@ -206,6 +206,7 @@ temp*/
.temp/
# AI
**/.checkpoints/
.claude/
.omc/
.omx/
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,55 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint0_linear_481_4810)"/>
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint1_linear_481_4810)"/>
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint2_linear_481_4810)"/>
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint3_linear_481_4810)"/>
<path d="M116.308 52.2209C111.903 52.2271 107.507 53.3498 103.561 55.6366C95.6702 60.1891 90.8239 68.6019 90.8231 77.6986L90.8223 167.846C90.8222 169.786 92.871 171.041 94.599 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9894C124.778 58.6476 129.49 55.9209 133.25 58.0698L128.879 55.5453C124.976 53.3242 120.645 52.2192 116.308 52.2209Z" fill="url(#paint4_linear_481_4810)"/>
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint5_linear_481_4810)"/>
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint6_linear_481_4810)"/>
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint7_linear_481_4810)"/>
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint8_linear_481_4810)"/>
<path d="M142.003 205.487C146.408 205.481 150.805 204.358 154.751 202.071C162.641 197.519 167.488 189.106 167.488 180.009L167.489 89.8618C167.489 87.9222 165.44 86.667 163.712 87.5479L154.788 92.0972C141.739 98.7494 133.523 112.159 133.523 126.806L133.523 194.719C133.533 199.06 128.821 201.787 125.061 199.638L129.432 202.163C133.336 204.384 137.666 205.489 142.003 205.487Z" fill="url(#paint9_linear_481_4810)"/>
<defs>
<linearGradient id="paint0_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
<stop stop-color="#9189F7"/>
<stop offset="1" stop-color="#4135E9"/>
</linearGradient>
<linearGradient id="paint1_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F42FD"/>
<stop offset="1" stop-color="#7274FF"/>
</linearGradient>
<linearGradient id="paint2_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
<stop stop-color="#9189F7"/>
<stop offset="1" stop-color="#4135E9"/>
</linearGradient>
<linearGradient id="paint3_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F42FD"/>
<stop offset="1" stop-color="#7274FF"/>
</linearGradient>
<linearGradient id="paint4_linear_481_4810" x1="93.1761" y1="128.826" x2="66.2399" y2="104.746" gradientUnits="userSpaceOnUse">
<stop offset="0.25" stop-color="#4F42FD"/>
<stop offset="1" stop-color="#2C08AC"/>
</linearGradient>
<linearGradient id="paint5_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
<stop stop-color="#9189F7"/>
<stop offset="1" stop-color="#4135E9"/>
</linearGradient>
<linearGradient id="paint6_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F42FD"/>
<stop offset="1" stop-color="#7274FF"/>
</linearGradient>
<linearGradient id="paint7_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
<stop stop-color="#9189F7"/>
<stop offset="1" stop-color="#4135E9"/>
</linearGradient>
<linearGradient id="paint8_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
<stop stop-color="#4F42FD"/>
<stop offset="1" stop-color="#7274FF"/>
</linearGradient>
<linearGradient id="paint9_linear_481_4810" x1="165.135" y1="128.882" x2="192.072" y2="152.962" gradientUnits="userSpaceOnUse">
<stop offset="0.25" stop-color="#4F42FD"/>
<stop offset="1" stop-color="#2C08AC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -0,0 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="black"/>
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,5 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="white"/>
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,4 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,4 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1125,7 +1125,7 @@ Naming (Python): N/A (Composable Components)
Supports: N
Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution.
For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception.
No specific code examples available for interception.
#### Smolagents (Hugging Face)
+12 -12
View File
@@ -41,19 +41,19 @@
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.12.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" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
<!-- OpenTelemetry -->
@@ -72,12 +72,12 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
@@ -86,12 +86,12 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
+3
View File
@@ -344,6 +344,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
</Folder>
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.9.0</VersionPrefix>
<VersionPrefix>1.10.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260603</DateSuffix>
<DateSuffix>260610</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.9.0</GitTag>
<GitTag>1.10.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -23,7 +23,7 @@
<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.19.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc4" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
<PackageReference Include="Neo4j.AgentFramework.GraphRAG" Version="0.1.0-preview.2" />
<PackageReference Include="Neo4j.Driver" Version="5.28.0" />
</ItemGroup>
@@ -79,8 +79,10 @@ AIAgent agent =
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
.AsHarnessAgent(new HarnessAgentOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
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
@@ -44,8 +44,10 @@ AIAgent webSearchAgent =
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
.AsHarnessAgent(new HarnessAgentOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
OpenTelemetrySourceName = TracingSourceName,
@@ -92,8 +94,10 @@ AIAgent parentAgent =
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
.AsHarnessAgent(new HarnessAgentOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using background agents.",
OpenTelemetrySourceName = TracingSourceName,
@@ -68,8 +68,10 @@ AIAgent agent =
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
.AsHarnessAgent(new HarnessAgentOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
OpenTelemetrySourceName = TracingSourceName,
@@ -89,8 +89,10 @@ AIAgent agent =
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
.AsHarnessAgent(new HarnessAgentOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
Name = "CodeExecutionAgent",
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
OpenTelemetrySourceName = TracingSourceName,
@@ -71,6 +71,34 @@ curl -X POST http://localhost:8088/invocations \
-d "Hello from Docker!"
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-invocations-echo-agent && cd hosted-invocations-echo-agent
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-invocations-echo-agent
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative.
@@ -107,3 +107,29 @@ azd env set SKILL_NAMES "support-style,escalation-policy"
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
### Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-agent-skills && cd hosted-agent-skills
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-agent-skills
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
@@ -174,6 +174,34 @@ The model receives the top three search results as additional context and cites
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-azure-search-rag && cd hosted-azure-search-rag
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-azure-search-rag
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## 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 `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
@@ -104,6 +104,32 @@ curl -X POST http://localhost:8088/responses \
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-chat-client-agent
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
## 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` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
@@ -112,6 +112,34 @@ docker run --rm -p 8088:8088 \
The bundled `resources/` folder is part of the published output and ships inside the image.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-files && cd hosted-files
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-files
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
@@ -107,6 +107,32 @@ curl -X POST http://localhost:8088/responses \
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-foundry-agent && cd hosted-foundry-agent
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-foundry-agent
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
## 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` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative.
@@ -108,6 +108,34 @@ The agent has a single tool `GetAvailableHotels` defined as a C# method with `[D
The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-local-tools && cd hosted-local-tools
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-local-tools
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## 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 `HostedLocalTools.csproj` for the `PackageReference` alternative.
@@ -78,6 +78,40 @@ docker run --rm -p 8088:8088 \
hosted-mcp-tools
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir mcp-tools && cd mcp-tools
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME mcp-tools
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
## Related samples
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`).
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
@@ -139,6 +139,34 @@ The script publishes the project, builds the image, runs the container with two
`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.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-memory-agent && cd hosted-memory-agent
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-memory-agent
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
@@ -104,6 +104,34 @@ docker run --rm -p 8088:8088 \
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-observability && cd hosted-observability
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-observability
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
@@ -111,6 +111,34 @@ The `TextSearchProvider` runs a mock search **before each model invocation**:
The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-text-rag && cd hosted-text-rag
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-text-rag
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## 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 `HostedTextRag.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,16 @@
# Azure AI Foundry project endpoint (auto-injected in hosted containers).
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
# Model deployment name. Must exist in the Foundry project above.
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
# Name of the Foundry Toolbox you provisioned in the portal (see README.md).
TOOLBOX_NAME=auth-paths-toolbox
# Agent name advertised over the wire. Must be unique if running side-by-side with
# other Hosted-* samples (e.g. Hosted-Toolbox), otherwise the REPL client cannot
# disambiguate which agent to chat with.
AGENT_NAME=hosted-toolbox-auth-paths-agent
# Application Insights connection string (auto-injected in hosted containers; optional locally).
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
@@ -0,0 +1,21 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local source, which means a standard
# multi-stage Docker build cannot resolve dependencies outside this folder.
# Pre-publish the app targeting the container runtime and copy the output:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-toolbox-auth-paths .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-toolbox-auth-paths-agent \
# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
# --env-file .env hosted-toolbox-auth-paths
#
# 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", "HostedToolboxAuthPaths.dll"]
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedToolboxAuthPaths</RootNamespace>
<AssemblyName>HostedToolboxAuthPaths</AssemblyName>
<NoWarn>$(NoWarn);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 ProjectReference 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>
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox Auth Paths Agent — A hosted agent backed by a single Foundry Toolbox
// that bundles MCP tools using THREE different authentication paths.
//
// This sample demonstrates the same hosting bones as Hosted-Toolbox/, but the toolbox
// (provisioned by the user out-of-band) contains three MCP tool entries each authenticated
// differently. The agent code itself is agnostic to authentication — the educational
// surface lives in the toolbox configuration in the Foundry portal and in this sample's
// README.md.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
// - Azure AI Foundry project endpoint. The Foundry hosted
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention).
// TOOLBOX_NAME - Name of the Foundry Toolbox to load
// (default: auth-paths-toolbox)
//
// Optional:
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
// AGENT_NAME - Defaults to "hosted-toolbox-auth-paths-agent".
//
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
// per tools-integration-spec.md §2–§3, so the sample does not need to plumb any
// toolbox-specific URL env var.
//
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones
// listed above) are reserved by the Foundry container platform and rejected by the
// agent-create API. Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for sample-owned config.
#pragma warning disable OPENAI001 // FoundryAITool.CreateHostedMcpToolbox is experimental
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;
// Load .env file if present (for local development)
Env.TraversePath().Load();
// Project endpoint resolution order:
// 1. FOUNDRY_PROJECT_ENDPOINT — auto-injected by the Foundry hosted runtime.
// 2. AZURE_AI_PROJECT_ENDPOINT — the convention developers set locally for `dotnet run`.
// When deployed, only (1) is available; the AF-repo sample convention to set (2) at
// deploy time fails silently because the platform reserves all FOUNDRY_* env-var names
// and rejects them at agent-create time. Read both, prefer the platform-injected one.
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException(
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "auth-paths-toolbox";
string agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-auth-paths-agent";
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
// toolbox's MCP tools once at startup and automatically makes them available to the
// agent on every request. The agent code does nothing per request.
// - Per-request / caller-driven (NOT used here): a client can attach a toolbox for a
// single call by placing a FoundryAITool.CreateHostedMcpToolbox(name) marker in the
// request body's tool list.
// Because this sample bakes the toolbox in on the server, it uses AddFoundryToolboxes and
// does NOT put the CreateHostedMcpToolbox marker in the agent's `tools:` array.
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: """
You are a helpful assistant with access to several tools, each provided by a different
upstream service authenticated through a distinct mechanism (API key, agent managed
identity, and a literal token
shipped with the tool definition). Pick the tool that best fits the user's question
and explain which upstream service answered when you respond.
""",
name: agentName,
description: "Hosted agent demonstrating three MCP-tool authentication paths via a Foundry Toolbox.");
// Tier 3 spine (WebApplication.CreateBuilder + AddFoundryResponses + MapFoundryResponses):
// the Foundry.Hosting package auto-maps the spec-required GET /readiness probe inside
// MapFoundryResponses (idempotent — skipped when AgentHost or the developer already
// mapped it), so the sample stays free of platform plumbing.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
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();
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);
}
}
@@ -0,0 +1,197 @@
# Hosted Toolbox — Authentication Paths
A hosted Foundry agent backed by a single Foundry Toolbox that bundles MCP tools using **three different authentication paths**. The educational surface lives in the toolbox configuration (which you provision in the Foundry portal) and in this README — the agent code itself is identical to the existing [`Hosted-Toolbox/`](../Hosted-Toolbox/) sample.
Drive the agent interactively across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent.
## What this sample teaches
| Aspect | This sample | Existing siblings |
|---|---|---|
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
| Consumption | Server-side (Foundry resolves the marker) | Same |
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
Related samples:
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — simpler single-tool toolbox.
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
## Authentication-path matrix
The sample's purpose is to enumerate every authentication path a Foundry toolbox can drive, so each path appears alongside the others. Pick the ones your scenario needs — each connection in a toolbox is independent.
| # | Auth method | MCP target | Connection `authType` | What flows where | When to pick this |
|---|---|---|---|---|---|
| 1 | **Key-based via project connection** | GitHub MCP at `https://api.githubcopilot.com/mcp` | `CustomKeys` | A PAT stored as `Authorization: Bearer <pat>` lives in the Foundry connection. The toolbox proxy reads it server-side and injects on every MCP call. | The upstream service only accepts API keys or PATs. |
| 2 | **Microsoft Entra — agent identity** | Any Azure Cognitive Services MCP endpoint your project can reach (e.g., Language service MCP) | `AgenticIdentityToken` | Foundry mints an Entra token for the agent's own identity (`instance_identity` in the new agent object model), scoped to the connection's `audience`, and forwards it to the MCP server. The agent identity must hold the required role (typically `Cognitive Services User`) on the target resource. | Per-agent least-privilege access to Entra-protected services. Recommended default for new agents. |
| 3 | **Inline `Authorization` (anti-pattern)** | `https://gitmcp.io/Azure/azure-rest-api-specs` | none | A literal bearer string lives on the toolbox tool entry's `authorization` field. **Do not do this in production** — there's no rotation, no secret store, no per-user identity. Shown for completeness. | Local-dev or public MCP servers that accept any (or no) bearer. |
## Prerequisites
### 0. (Path #2 only) Identify an Entra-authenticated MCP target
Path #2 requires an MCP server that accepts Microsoft Entra tokens. Any **Azure Cognitive Services** resource that exposes an MCP endpoint works — they all accept Entra ID tokens and gate access via standard RBAC.
The reference walkthrough below uses an **Azure Language service** MCP endpoint:
```
https://<your-language-service>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview
```
Substitute any other Cognitive Services MCP endpoint you have. If your project has none, omit tool #2 from your toolbox — the remaining two paths still work.
#### RBAC for path #2
Grant the **`Cognitive Services User`** role on the target resource to the agent's instance identity. Find it on the agent ARM resource (Azure portal → your agent → JSON view) at `instance_identity.principal_id`. This is the principal the Foundry proxy uses when minting tokens for `AgenticIdentityToken` connections.
```powershell
$lang = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<lang-svc>"
az role assignment create `
--assignee-object-id <agent-instance-identity-principal-id> `
--assignee-principal-type ServicePrincipal `
--role "Cognitive Services User" `
--scope $lang
```
Repeat for any additional Cognitive Services resources the agent identity needs to call.
> The RBAC grant requires `Microsoft.Authorization/roleAssignments/write` on the target scope. In many enterprise subscriptions this needs a PIM JIT activation.
### 1. Foundry project + Azure AI User role
- An active Microsoft Foundry project ([create one](https://learn.microsoft.com/en-us/azure/foundry/how-to/create-projects)).
- The **Azure AI User** role on the project assigned to:
- The developer (you) creating the toolbox.
- The agent identity for tool invocation.
### 2. Create the project connections
The Entra-based connection (path #2) is not available in the Foundry portal connection wizard today. Create it via ARM REST:
```powershell
$armToken = az account get-access-token --query accessToken -o tsv
$h = @{ Authorization = "Bearer $armToken"; "Content-Type" = "application/json" }
$proj = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<foundry-account>/projects/<project>"
$lang = "https://<lang-svc>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview"
# Path 2 — agent identity
$body2 = @{ properties = @{
category = "RemoteTool"; target = $lang
authType = "AgenticIdentityToken"; audience = "https://cognitiveservices.azure.com"
isSharedToAll = $false
}} | ConvertTo-Json -Depth 5
az rest --method PUT --headers "Content-Type=application/json" `
--url "https://management.azure.com$proj/connections/lang-mcp-agent-id?api-version=2025-04-01-preview" `
--body $body2
```
Connection summary:
| Connection name (used by the toolbox) | `category` | `authType` | `audience` |
|---|---|---|---|
| `github-mcp-key` | `CustomKeys` | `CustomKeys` | n/a (key value carries `Authorization: Bearer <pat>`) |
| `lang-mcp-agent-id` | `RemoteTool` | `AgenticIdentityToken` | `https://cognitiveservices.azure.com` |
Path #3 (`gitmcp.io`) needs no connection — the auth lives on the toolbox tool entry itself.
The `audience` value is the token resource identifier of the target service — for any Cognitive Services resource it is `https://cognitiveservices.azure.com`. For other Azure services consult [Agent identity — runtime token exchange](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-identity#runtime-token-exchange).
### 3. Create the toolbox
In the Foundry portal → Tools → Add Toolbox. Name it `auth-paths-toolbox` (or whatever you prefer; export the name as `TOOLBOX_NAME`). Add three MCP tool entries:
| Tool `server_label` | `server_url` | Auth |
|---|---|---|
| `github_pat` | `https://api.githubcopilot.com/mcp` | `project_connection_id: github-mcp-key` |
| `lang_agent` | Your Language service MCP URL | `project_connection_id: lang-mcp-agent-id` |
| `gitmcp_inline` | `https://gitmcp.io/Azure/azure-rest-api-specs` | `authorization: "Bearer demo-only-not-real"` (no `project_connection_id`) |
Each entry should also carry:
- `require_approval: never` (this sample is focused on auth, not approval flows; see [`ToolCallingApprovalHostedAgentFixture.cs`](../../../../../tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolCallingApprovalHostedAgentFixture.cs) for that concern).
- A tight `allowed_tools` list. GitHub MCP exposes ~50 tools; restrict to what you actually want the model to invoke. For example: `github_pat` → `["search_issues", "list_pull_requests"]`. **Every name in `allowed_tools` must match a real tool on the upstream server** — an unknown name (e.g., `get_issue`, which GitHub MCP does not expose) makes the whole source fail enumeration. See the partial-failure note below.
### Sidebar — what the toolbox-creation code looks like
This sample assumes the toolbox already exists; it does not provision one programmatically. For an end-to-end code example of toolbox creation from a publisher script (suitable for a CI/CD pipeline), see [`02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs`](../../../../02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs) — its `CreateSampleToolboxAsync` helper uses `AgentAdministrationClient.GetAgentToolboxes().CreateToolboxVersionAsync(...)` and is the canonical pattern.
## Run the agent
Set environment variables (or copy `.env.example` to `.env` and fill it in):
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
$env:TOOLBOX_NAME = "auth-paths-toolbox"
```
Locally, the `Foundry.Hosting` package reads `AZURE_AI_PROJECT_ENDPOINT` as a fallback when `FOUNDRY_PROJECT_ENDPOINT` is absent. In the hosted Foundry runtime, the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT` and the package builds the toolbox proxy URL as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
Then sign in (`az login`) and start the server:
```powershell
dotnet run --tl:off
```
The server logs at `http://localhost:8088/`. In Development it also maps the per-agent OpenAI route shape (`MapDevTemporaryLocalAgentEndpoint()`), so the shared `SimpleAgent` REPL client can reach it through `AsAIAgent(agentEndpoint)` — the only supported way to consume a hosted Foundry agent. In a separate terminal:
**Against the local dev server** (point the client at localhost; the `{project}` segment is a wildcard the server ignores):
```powershell
cd ../Using-Samples/SimpleAgent
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
dotnet run --tl:off
```
**Against a deployed agent** (point the client at the real project endpoint and the deployed agent name):
```powershell
cd ../Using-Samples/SimpleAgent
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
dotnet run --tl:off
```
Either way the client derives the per-agent endpoint URL (`{AZURE_AI_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) and consumes the agent via `AsAIAgent(agentEndpoint)`. Run `az login` first so the client can mint a bearer token.
> **Parallel-run warning**: `Hosted-Toolbox/` and other `Hosted-*` samples default to the same port (8088) and the same agent name slot. Always set a unique `AGENT_NAME` (this sample defaults to `hosted-toolbox-auth-paths-agent`) and stop other hosted samples before starting this one.
## Sample prompts
One per auth path so each tool gets exercised at least once:
```
List the latest 3 issues in microsoft/agent-framework. # path #1 — GitHub MCP (key)
Detect the language of "Bonjour le monde". # path #2 — Language MCP (agent identity)
What's the latest API version for Microsoft.CognitiveServices? # path #3 — gitmcp.io (inline Authorization)
```
## Troubleshooting / partial-failure semantics
`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. This enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate, the Foundry toolbox proxy returns a top-level JSON-RPC error (`-32007`) instead of a partial list, the hosting package marks the toolbox startup as failed, `/readiness` returns 503, and *every* invoke against the agent returns **HTTP 424** — even for the auth paths that are configured correctly. So one misconfigured connection or one bad `allowed_tools` entry bricks the whole agent at startup, not just at tool-call time. Get each source enumerating cleanly before deploying. Symptoms per auth path:
| Symptom | Likely cause |
|---|---|
| **All invokes return HTTP 424 ("Failed Dependency")** | One or more tool sources failed `tools/list` at startup (see all-or-nothing note above). Common causes: an `allowed_tools` name that does not exist on the upstream server, or an Entra connection whose token is rejected. Reproduce by calling the toolbox `tools/list` directly with your own token — a `-32007` top-level error names the failing source. |
| **HTTP 401 "audience is incorrect"** | The connection's `audience` field is missing or does not match the OAuth resource identifier the target service accepts. For Cognitive Services targets, set `audience: "https://cognitiveservices.azure.com"`. |
| **HTTP 401 / 403 "principal does not have access"** | Path #1: PAT expired or scope insufficient. Path #2: the agent's instance identity is missing the required role on the target resource. |
| **Container reports zero tools but startup succeeded** | `FoundryToolboxService.StartAsync` caches the `tools/list` result at startup. If a connection or RBAC grant changed after the container started, force a fresh container (re-deploy the agent version) — the cache won't pick up the change until then. |
| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. |
| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. |
| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. |
## Region and model compatibility
Foundry Toolboxes have region constraints; some tool types are limited to specific models. This sample defaults to `gpt-4o`, which works in all supported regions. For the full matrix, see the [Foundry tools compatibility matrix](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#region-and-model-compatibility).
## Anti-pattern note for path #3
Inline `authorization` on a toolbox tool entry stores credentials **inside the toolbox definition**. There is no rotation, no per-user scoping, no secret-store integration. Use it only for:
- Public MCP servers that ignore the bearer (the `gitmcp.io` case demonstrated here).
- Local development against a test MCP server with a throwaway token.
For everything else use `project_connection_id` and let the platform inject credentials.
@@ -0,0 +1,48 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-toolbox-auth-paths
displayName: "Hosted Toolbox - Authentication Paths"
description: >
A hosted agent demonstrating three MCP-tool authentication paths in a single
Foundry Toolbox: API key via project connection, Microsoft Entra agent
identity, and inline Authorization
(anti-pattern). The toolbox itself is
provisioned out of band; see this sample's README for the portal walkthrough.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- Foundry Toolbox
- Authentication
- MCP
template:
name: hosted-toolbox-auth-paths
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME
value: "{{TOOLBOX_NAME}}"
parameters:
properties:
- name: TOOLBOX_NAME
type: string
default: "auth-paths-toolbox"
description: "Name of the Foundry Toolbox to load at runtime."
resources:
- kind: model
id: gpt-4o
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
- kind: toolbox
name: "{{TOOLBOX_NAME}}"
tools: []
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-toolbox-auth-paths
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -1,21 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools.
//
// Demonstrates how to register one or more Foundry toolsets so the agent can
// Demonstrates how to register one or more Foundry toolboxes so the agent can
// call tools provided by the Foundry platform's managed MCP proxy.
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
// - Azure AI Foundry project endpoint. The Foundry hosted
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
// set AZURE_AI_PROJECT_ENDPOINT.
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
// (injected automatically by Foundry platform at runtime)
//
// Optional:
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox)
// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime)
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime)
// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory
// Toolboxes=V1Preview flag is always sent; this env var
// appends additional flags if present).
//
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
// per tools-integration-spec.md §2–§3.
using Azure.AI.Projects;
using Azure.Core;
@@ -28,10 +34,13 @@ using Microsoft.Agents.AI.Foundry.Hosting;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException(
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox";
// 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 production).
@@ -45,12 +54,12 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
.AsAIAgent(
model: deploymentName,
instructions: """
You are a helpful assistant with access to tools provided by the Foundry Toolset.
You are a helpful assistant with access to tools provided by the Foundry Toolbox.
Use the available tools to answer user questions.
If a tool is not available for a request, let the user know clearly.
""",
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
description: "Hosted agent backed by Foundry Toolset MCP tools");
description: "Hosted agent backed by Foundry Toolbox MCP tools");
// ── Build the host ────────────────────────────────────────────────────────────
@@ -61,8 +70,8 @@ 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.
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
// The toolbox name must match a toolbox registered in your Foundry project.
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
builder.Services.AddFoundryToolboxes(toolboxName);
@@ -0,0 +1,27 @@
# Hosted-Toolbox
A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge.
The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`.
## Prerequisites
- A Microsoft Foundry project with a Toolbox configured.
- Azure CLI logged in (`az login`).
- Set environment variables:
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers)
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`)
- `TOOLBOX_NAME` (default `my-toolbox`)
The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
## Run
```powershell
dotnet run --tl:off
```
## Related samples
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
@@ -98,6 +98,34 @@ Using the Azure Developer CLI:
azd ai agent invoke --local "What skills do you have available?"
```
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-toolbox-mcp-skills && cd hosted-toolbox-mcp-skills
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-toolbox-mcp-skills
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-5
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## 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 `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
@@ -121,6 +121,34 @@ User message
The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir triage-workflow && cd triage-workflow
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME triage-workflow
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
@@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
- Azure CLI logged in (`az login`)
## Configuration
@@ -22,7 +22,7 @@ Edit `.env` and set your Azure AI Foundry project endpoint:
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_AI_MODEL_DEPLOYMENT_NAME=hosted-workflow-simple
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
@@ -104,6 +104,34 @@ Input text
Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops.
## Deploying to Foundry (azd spec)
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
Initialize an `azd` project from this sample's manifest:
```bash
mkdir hosted-workflows && cd hosted-workflows
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml
```
Then deploy:
```bash
azd deploy
```
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
```bash
azd env set AGENT_NAME hosted-workflow-simple
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME hosted-workflow-simple
```
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
---
## NuGet package users
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative.
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -13,24 +14,32 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="DelegatingHandler"/> that:
/// <list type="bullet">
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://ai.azure.com/.default</c>) per request, per <c>tools-integration-spec.md</c> §4.</item>
/// <item>Always injects the mandatory <c>Foundry-Features: Toolboxes=V1Preview</c> header per spec §2, merging any additional flags from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c>.</item>
/// <item>Propagates W3C trace context (<c>traceparent</c>, <c>tracestate</c>, <c>baggage</c>) from <see cref="Activity.Current"/> per spec §6.3.</item>
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
/// </list>
/// </summary>
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
{
private const int MaxRetries = 3;
// Per tools-integration-spec.md §4, the container authenticates to the Foundry Toolbox
// proxy with a bearer token whose audience is https://ai.azure.com.
private static readonly TokenRequestContext s_tokenContext =
new(["https://cognitiveservices.azure.com/.default"]);
new(["https://ai.azure.com/.default"]);
// Per tools-integration-spec.md §2, every proxy request MUST include the
// Foundry-Features: Toolboxes=V1Preview opt-in header while the service is in preview.
private const string MandatoryFeatureFlag = "Toolboxes=V1Preview";
private readonly TokenCredential _credential;
private readonly string? _featuresHeaderValue;
private readonly string? _additionalFeaturesHeaderValue;
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue)
{
this._credential = credential;
this._featuresHeaderValue = featuresHeaderValue;
this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue;
}
protected override async Task<HttpResponseMessage> SendAsync(
@@ -43,10 +52,9 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
{
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
}
request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue));
PropagateTraceContext(request);
// MaxRetries is the total number of attempts (not additional retries after the first).
for (int attempt = 0; attempt < MaxRetries; attempt++)
@@ -82,6 +90,75 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
throw new InvalidOperationException("Retry loop completed without returning a response.");
}
// Returns "Toolboxes=V1Preview" when no override is set, or
// "Toolboxes=V1Preview,<override-value>" when an override is set and doesn't already include it.
internal static string BuildFeaturesHeaderValue(string? additional)
{
if (string.IsNullOrWhiteSpace(additional))
{
return MandatoryFeatureFlag;
}
// Avoid duplicating the mandatory flag if the override happens to already include it
// (case-insensitive, ignore surrounding whitespace).
foreach (var part in additional!.Split(','))
{
if (string.Equals(part.Trim(), MandatoryFeatureFlag, StringComparison.OrdinalIgnoreCase))
{
return additional;
}
}
return $"{MandatoryFeatureFlag},{additional}";
}
// Per tools-integration-spec.md §6.3, propagate W3C trace context onto outbound requests.
// Skip headers already set on the message (callers / inner handlers may override).
private static void PropagateTraceContext(HttpRequestMessage request)
{
var activity = Activity.Current;
if (activity is null)
{
return;
}
if (!request.Headers.Contains("traceparent"))
{
var traceparent = activity.Id;
if (!string.IsNullOrEmpty(traceparent))
{
request.Headers.TryAddWithoutValidation("traceparent", traceparent);
}
}
var traceState = activity.TraceStateString;
if (!string.IsNullOrEmpty(traceState) && !request.Headers.Contains("tracestate"))
{
request.Headers.TryAddWithoutValidation("tracestate", traceState);
}
// Baggage is a comma-separated list of key=value pairs per the W3C Baggage spec.
if (!request.Headers.Contains("baggage"))
{
string? baggageHeader = null;
foreach (var pair in activity.Baggage)
{
if (pair.Value is null)
{
continue;
}
var entry = $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}";
baggageHeader = baggageHeader is null ? entry : $"{baggageHeader},{entry}";
}
if (baggageHeader is not null)
{
request.Headers.TryAddWithoutValidation("baggage", baggageHeader);
}
}
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original,
CancellationToken cancellationToken)
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Adapts <see cref="FoundryToolboxService.StartupStatus"/> to the AspNetCore
/// HealthChecks pipeline so the <c>GET /readiness</c> probe (mapped by
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>) reflects whether
/// pre-registered toolbox connections are usable. Registered automatically by
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, string[])"/>
/// and its overloads.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class FoundryToolboxHealthCheck : IHealthCheck
{
private readonly FoundryToolboxService _toolboxService;
public FoundryToolboxHealthCheck(FoundryToolboxService toolboxService)
{
ArgumentNullException.ThrowIfNull(toolboxService);
this._toolboxService = toolboxService;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
switch (this._toolboxService.StartupStatus)
{
case FoundryToolboxStartupStatus.Healthy:
return Task.FromResult(HealthCheckResult.Healthy(
description: $"Foundry toolbox: {this._toolboxService.Tools.Count} tool(s) available."));
case FoundryToolboxStartupStatus.NoEndpoint:
return Task.FromResult(HealthCheckResult.Healthy(
description: "Foundry toolbox: neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support disabled (local dev)."));
case FoundryToolboxStartupStatus.Pending:
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: "Foundry toolbox: startup has not completed yet."));
case FoundryToolboxStartupStatus.Unhealthy:
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["failedToolboxes"] = this._toolboxService.FailedToolboxNames,
};
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: $"Foundry toolbox: {this._toolboxService.FailedToolboxNames.Count} pre-registered toolbox(es) failed to open at startup.",
data: data));
default:
return Task.FromResult(new HealthCheckResult(
status: context.Registration.FailureStatus,
description: $"Foundry toolbox: unknown startup status '{this._toolboxService.StartupStatus}'."));
}
}
}
@@ -16,14 +16,15 @@ public sealed class FoundryToolboxOptions
/// Gets the list of toolbox names to connect to at startup.
/// Each name corresponds to a toolbox registered in the Foundry project.
/// The platform proxy URL is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// per <c>tools-integration-spec.md</c> §2–§3.
/// </summary>
public IList<string> ToolboxNames { get; } = [];
/// <summary>
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
/// Gets or sets the Toolboxes API version to use when constructing proxy URLs.
/// </summary>
public string ApiVersion { get; set; } = "2025-05-01-preview";
public string ApiVersion { get; set; } = "v1";
/// <summary>
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
@@ -36,7 +37,9 @@ public sealed class FoundryToolboxOptions
public bool StrictMode { get; set; } = true;
/// <summary>
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
/// For testing only: overrides the toolbox proxy base URL (skipping the
/// <c>FOUNDRY_PROJECT_ENDPOINT</c>-derived default). When set, the proxy URL
/// becomes <c>{EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
/// Not part of the public API.
/// </summary>
internal string? EndpointOverride { get; set; }
@@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// </summary>
/// <remarks>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
/// The toolbox proxy base URL is derived from the platform-injected
/// <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable per <c>tools-integration-spec.md</c>
/// §2–§3. The per-toolbox proxy URL is constructed as
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
/// </para>
/// <para>
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent the service starts without error and
/// no tools are registered, keeping the container healthy per spec §2.
/// </para>
/// <para>
@@ -56,6 +62,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// </summary>
public IReadOnlyList<AITool> Tools { get; private set; } = [];
/// <summary>
/// Gets the startup status of the service. Reflects the outcome of pre-registered
/// toolbox connections opened in <see cref="StartAsync"/>; lazy-opens triggered by
/// per-request markers do not change this value.
/// </summary>
/// <remarks>
/// Consumed by <see cref="FoundryToolboxHealthCheck"/> to gate the
/// <c>GET /readiness</c> probe so the Foundry hosted runtime does not start routing
/// traffic to a container whose pre-registered toolbox failed to open at startup.
/// </remarks>
public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending;
/// <summary>
/// Gets the names of pre-registered toolboxes that failed to open during
/// <see cref="StartAsync"/>. Empty when startup was successful or has not run yet.
/// </summary>
public IReadOnlyList<string> FailedToolboxNames { get; private set; } = [];
/// <summary>
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
/// </summary>
@@ -75,16 +99,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
this._resolvedEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
// Per tools-integration-spec.md §2-§3, the container derives the toolbox proxy base
// URL from the platform-injected FOUNDRY_PROJECT_ENDPOINT. The EndpointOverride
// option exists for tests; AZURE_AI_PROJECT_ENDPOINT is honored as a local-dev
// fallback to mirror the convention used by AF-repo samples.
var projectEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
if (string.IsNullOrEmpty(this._resolvedEndpoint))
if (string.IsNullOrEmpty(projectEndpoint))
{
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
this._logger.LogWarning(
"Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled.");
this.Tools = [];
this.StartupStatus = FoundryToolboxStartupStatus.NoEndpoint;
return;
}
this._resolvedEndpoint = projectEndpoint.TrimEnd('/');
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
@@ -93,10 +125,12 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
{
this._logger.LogInformation("No pre-registered toolbox names configured.");
this.Tools = [];
this.StartupStatus = FoundryToolboxStartupStatus.Healthy;
return;
}
var allTools = new List<AITool>();
var failed = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var toolboxName in this._options.ToolboxNames)
@@ -121,10 +155,16 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
toolboxName);
}
failed.Add(toolboxName);
}
}
this.Tools = allTools;
this.FailedToolboxNames = failed;
this.StartupStatus = failed.Count == 0
? FoundryToolboxStartupStatus.Healthy
: FoundryToolboxStartupStatus.Unhealthy;
}
/// <summary>
@@ -165,7 +205,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
throw new InvalidOperationException(
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set.");
}
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -192,7 +232,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
string? version,
CancellationToken cancellationToken)
{
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
if (this._logger.IsEnabled(LogLevel.Information))
{
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Outcome of <see cref="FoundryToolboxService"/> startup. Drives the
/// <c>foundry-toolbox</c> health-check that gates the <c>GET /readiness</c> probe so the
/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox
/// connections are confirmed open (per <c>container-image-spec.md</c> §3.1).
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum FoundryToolboxStartupStatus
{
/// <summary>
/// <see cref="FoundryToolboxService.StartAsync"/> has not run yet. The health-check
/// reports <c>Unhealthy</c> in this state so the platform waits for startup to
/// complete before the first invocation.
/// </summary>
Pending = 0,
/// <summary>
/// Startup completed and either every pre-registered toolbox opened successfully or
/// no pre-registered toolboxes were configured. The health-check reports
/// <c>Healthy</c>.
/// </summary>
Healthy = 1,
/// <summary>
/// One or more pre-registered toolboxes failed to open during startup (including the
/// partial case where some opened and some did not). The health-check reports
/// <c>Unhealthy</c> and exposes the failed names in the <c>HealthCheckResult.Data</c>
/// dictionary so operators can diagnose the failure without parsing log output.
/// </summary>
Unhealthy = 2,
/// <summary>
/// Neither the <c>FOUNDRY_PROJECT_ENDPOINT</c> nor the <c>AZURE_AI_PROJECT_ENDPOINT</c>
/// environment variable is set. This is normal for local <c>dotnet run</c> flows and the
/// health-check reports <c>Healthy</c> so the container is still routable; toolbox tools
/// will simply not be available.
/// </summary>
NoEndpoint = 3,
}
@@ -13,7 +13,7 @@
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedRedaction>true</InjectSharedRedaction>
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
<NoWarn>$(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
@@ -7,10 +7,12 @@ using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -49,6 +51,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.AddHealthChecks();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
@@ -84,6 +87,7 @@ public static class FoundryHostingExtensions
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
services.AddHealthChecks();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
@@ -109,10 +113,10 @@ public static class FoundryHostingExtensions
/// <para>
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
/// project. The proxy URL per toolbox is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1</c>
/// </para>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent, startup succeeds without error and
/// no tools are loaded (the container remains healthy per spec §2).
/// </para>
/// <para>
@@ -167,12 +171,61 @@ public static class FoundryHostingExtensions
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
// Register the toolbox health check on the same /readiness pipeline that
// MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness
// probe (per container-image-spec.md §3.1) on the outcome of the pre-registered
// toolbox connections opened in FoundryToolboxService.StartAsync.
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against duplicate
// registration when AddFoundryToolboxes is called multiple times.
const string HealthCheckName = "foundry-toolbox";
services.AddHealthChecks();
services.Configure<HealthCheckServiceOptions>(opts =>
{
foreach (var existing in opts.Registrations)
{
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
{
return;
}
}
opts.Registrations.Add(new HealthCheckRegistration(
name: HealthCheckName,
factory: sp => ActivatorUtilities.CreateInstance<FoundryToolboxHealthCheck>(sp),
failureStatus: HealthStatus.Unhealthy,
tags: ["foundry", "toolbox", "readiness"]));
});
return services;
}
/// <summary>
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
/// </summary>
/// <remarks>
/// <para>
/// Also maps the Foundry-required <c>GET /readiness</c> health probe to
/// <see cref="HealthCheckEndpointRouteBuilderExtensions.MapHealthChecks(IEndpointRouteBuilder, string)"/>
/// when no <c>/readiness</c> route is already registered. This makes the package
/// spec-compliant in the Foundry hosted runtime (which probes <c>/readiness</c>
/// before accepting any invocation per <c>container-image-spec.md</c> §2; without
/// it every request fails with HTTP 424 <c>session_not_ready</c>) regardless of the
/// host spine the developer chose:
/// </para>
/// <list type="bullet">
/// <item><description><b>Tier 1/2</b> (<c>AgentHost.CreateBuilder</c>) — the Core SDK
/// already maps <c>/readiness</c>. The duplicate-route guard below skips
/// re-mapping it.</description></item>
/// <item><description><b>Tier 3</b> (<c>WebApplication.CreateBuilder</c> +
/// <c>AddFoundryResponses</c> + <c>MapFoundryResponses</c>) — the Core SDK
/// does NOT map it. This call covers the gap automatically.</description></item>
/// </list>
/// <para>
/// Developers can still opt out by registering their own <c>/readiness</c> route
/// before calling <c>MapFoundryResponses</c>; the existing route is detected and
/// preserved.
/// </para>
/// </remarks>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
/// <returns>The endpoint route builder for chaining.</returns>
@@ -180,9 +233,37 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
MapReadinessIfMissing(endpoints);
return endpoints;
}
/// <summary>
/// Maps <c>GET /readiness</c> to the AspNetCore HealthChecks pipeline only when no
/// route already serves that path. The duplicate guard scans
/// <see cref="EndpointDataSource"/> entries by route pattern, which catches both the
/// SDK-mapped <c>MapHealthChecks("/readiness")</c> path used by
/// <c>AgentHostBuilder</c> and any user-registered <c>app.MapGet("/readiness", ...)</c>
/// route. Idempotent across multiple <c>MapFoundryResponses</c> invocations.
/// </summary>
private static void MapReadinessIfMissing(IEndpointRouteBuilder endpoints)
{
const string ReadinessPath = "/readiness";
foreach (var dataSource in endpoints.DataSources)
{
foreach (var endpoint in dataSource.Endpoints)
{
if (endpoint is RouteEndpoint route &&
string.Equals(route.RoutePattern.RawText, ReadinessPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
}
endpoints.MapHealthChecks(ReadinessPath);
}
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// </summary>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
@@ -32,4 +32,52 @@
<Description>Provides Microsoft Agent Framework support for GitHub Copilot SDK.</Description>
</PropertyGroup>
<!--
buildTransitive bridge for GitHub.Copilot.SDK's CLI binary-download targets.
GitHub.Copilot.SDK ships its CLI download targets under build/, which NuGet
only auto-imports for projects with a DIRECT PackageReference to the SDK.
Consumers of this package (who reference Microsoft.Agents.AI.GitHub.Copilot
instead of GitHub.Copilot.SDK directly) would otherwise get only the managed
adapter .dll, no copilot.exe in their output, and a runtime failure at the
first RunAsync call.
The targets file in buildTransitive/ is static and contains all of the
Condition / Import logic. The .props file generated here just bakes the
SDK version this package was built against into a dedicated property
($(_MicrosoftAgentsAICopilotSdkPackagedVersion)) — no Condition or
inner $(...) reference in the generated file, so we avoid any MSBuild
escape gymnastics during string construction. The static targets file
then defaults $(_MicrosoftAgentsAICopilotSdkVersion) to the packaged
version unless the consumer overrides it.
-->
<ItemGroup>
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.targets" Pack="true" PackagePath="buildTransitive\" />
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
<Target Name="_GenerateBuildTransitiveProps" BeforeTargets="Build;Pack;_GetPackageFiles">
<ItemGroup>
<_CopilotSdkPackageVersion Include="@(PackageVersion)" Condition="'%(Identity)' == 'GitHub.Copilot.SDK'" />
</ItemGroup>
<PropertyGroup>
<_CopilotSdkResolvedVersion>@(_CopilotSdkPackageVersion->'%(Version)')</_CopilotSdkResolvedVersion>
</PropertyGroup>
<Error Condition="'$(_CopilotSdkResolvedVersion)' == ''"
Text="Could not resolve GitHub.Copilot.SDK version from PackageVersion items. Ensure the central package version is declared in Directory.Packages.props." />
<PropertyGroup>
<_BuildTransitivePropsContent>
<![CDATA[<Project>
<PropertyGroup>
<_MicrosoftAgentsAICopilotSdkPackagedVersion>$(_CopilotSdkResolvedVersion)</_MicrosoftAgentsAICopilotSdkPackagedVersion>
</PropertyGroup>
</Project>]]>
</_BuildTransitivePropsContent>
</PropertyGroup>
<WriteLinesToFile File="$(MSBuildThisFileDirectory)buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props"
Lines="$(_BuildTransitivePropsContent)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
</Target>
</Project>
@@ -0,0 +1,3 @@
# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj.
Microsoft.Agents.AI.GitHub.Copilot.props
@@ -0,0 +1,34 @@
<Project>
<!--
Bridge GitHub.Copilot.SDK's build/ targets to transitive consumers.
GitHub.Copilot.SDK ships its CLI download / binary-copy MSBuild targets under
build/, which means they only auto-import for projects with a DIRECT
PackageReference to GitHub.Copilot.SDK. Consumers of this package
(Microsoft.Agents.AI.GitHub.Copilot) would otherwise get only the managed
adapter .dll, no copilot CLI binary in their output, and the SDK would fail
at runtime with:
Copilot CLI not found at 'bin/{config}/{tfm}/runtimes/{rid}/native/copilot.exe'
This file ships under buildTransitive/ so NuGet auto-imports it for every
transitive consumer, locates the SDK in the NuGet package cache, and imports
the SDK's build/ targets so the CLI binary gets downloaded and copied to the
consumer's output as expected.
The companion .props file is generated at this package's pack time and sets
$(_MicrosoftAgentsAICopilotSdkPackagedVersion) to the SDK version this
package was built against. The Condition below uses that as the default for
$(_MicrosoftAgentsAICopilotSdkVersion), so consumers may override the SDK
version path by setting $(_MicrosoftAgentsAICopilotSdkVersion) before this
file is imported. Consumers may also opt out of the binary download via the
SDK's own $(CopilotSkipCliDownload)=true, which the SDK targets honor.
-->
<PropertyGroup>
<_MicrosoftAgentsAICopilotSdkVersion Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' == ''">$(_MicrosoftAgentsAICopilotSdkPackagedVersion)</_MicrosoftAgentsAICopilotSdkVersion>
<_MicrosoftAgentsAICopilotSdkTargetsPath Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' != ''">$([System.IO.Path]::Combine('$(NuGetPackageRoot)', 'github.copilot.sdk', '$(_MicrosoftAgentsAICopilotSdkVersion)', 'build', 'GitHub.Copilot.SDK.targets'))</_MicrosoftAgentsAICopilotSdkTargetsPath>
</PropertyGroup>
<Import Project="$(_MicrosoftAgentsAICopilotSdkTargetsPath)"
Condition="'$(_MicrosoftAgentsAICopilotSdkTargetsPath)' != '' And Exists('$(_MicrosoftAgentsAICopilotSdkTargetsPath)')" />
</Project>
@@ -16,23 +16,16 @@ public static class ChatClientHarnessExtensions
{
/// <summary>
/// Creates a new <see cref="HarnessAgent"/> that wraps this <see cref="IChatClient"/> with a pre-configured
/// pipeline including function invocation, per-service-call chat history persistence, and in-loop compaction.
/// pipeline including function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
/// of default context providers and agent decorators.
/// </summary>
/// <param name="chatClient">
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
/// </param>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="options">
/// Optional configuration options for the agent, including instructions override, tools,
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// additional context providers, chat history provider, and compaction settings.
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
/// </param>
/// <param name="loggerFactory">
/// Optional logger factory for creating loggers used by the agent and its components.
@@ -43,10 +36,8 @@ public static class ChatClientHarnessExtensions
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
public static HarnessAgent AsHarnessAgent(
this IChatClient chatClient,
int maxContextWindowTokens,
int maxOutputTokens,
HarnessAgentOptions? options = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
new(chatClient, options, loggerFactory, services);
}
@@ -18,50 +18,65 @@ 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
/// function invocation, per-service-call chat history persistence, optional in-loop compaction, and a rich set
/// of default context providers and agent decorators.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
/// <see cref="HarnessAgent"/> provides an opinionated, batteries-included agent suitable for
/// interactive agentic scenarios such as research, coding, data analysis, and general task automation.
/// It assembles a full pipeline from a caller-supplied <see cref="IChatClient"/> so that callers
/// only need to configure the parts they want to customize.
/// </para>
/// <para>
/// <strong>Chat client pipeline (inner to outer):</strong>
/// <list type="number">
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation with configurable iteration limits.</description></item>
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream (e.g., for user interrupts).</description></item>
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop, enabling crash recovery and history inspection.</description></item>
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window. Only included when <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> and <see cref="HarnessAgentOptions.MaxOutputTokens"/> are both provided.</description></item>
/// </list>
/// </para>
/// <para>
/// By default, the following context providers are included (each can be disabled via <see cref="HarnessAgentOptions"/>):
/// <strong>Context providers (each enabled by default, individually disableable via <see cref="HarnessAgentOptions"/>):</strong>
/// <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>
/// <item><description><see cref="TodoProvider"/> — persistent todo list that the agent uses to track multi-step plans. Disable with <see cref="HarnessAgentOptions.DisableTodoProvider"/>.</description></item>
/// <item><description><see cref="AgentModeProvider"/> — mode tracking (e.g., "plan" vs "execute") that the agent uses to structure its work. Disable with <see cref="HarnessAgentOptions.DisableAgentModeProvider"/>.</description></item>
/// <item><description><see cref="FileMemoryProvider"/> — file-based session memory allowing the agent to persist notes and artifacts across turns. Disable with <see cref="HarnessAgentOptions.DisableFileMemory"/>.</description></item>
/// <item><description><see cref="FileAccessProvider"/> — shared file access providing read/write tools for a working directory. Disable with <see cref="HarnessAgentOptions.DisableFileAccess"/>.</description></item>
/// <item><description><see cref="AgentSkillsProvider"/> — discovers and loads skill definitions from the file system, enabling dynamic tool sets. Disable with <see cref="HarnessAgentOptions.DisableAgentSkillsProvider"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// The agent is also wrapped with the following decorators by default (each can be disabled):
/// <strong>Optional context providers (enabled via <see cref="HarnessAgentOptions"/>):</strong>
/// <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>
/// <item><description><see cref="BackgroundAgentsProvider"/> — enables delegation to background agents for parallel work. Enable by setting <see cref="HarnessAgentOptions.BackgroundAgents"/>.</description></item>
/// <item><description><c>ShellEnvironmentProvider</c> — injects OS/shell/CWD information and a shell execution tool. Enable by setting <c>HarnessAgentOptions.ShellExecutor</c> (.NET only).</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"/>).
/// <strong>Agent decorators (each enabled by default, individually disableable):</strong>
/// <list type="bullet">
/// <item><description><see cref="ToolApprovalAgent"/> — "don't ask again" tool approval rules enabling safe unattended execution. Disable with <see cref="HarnessAgentOptions.DisableToolApproval"/>.</description></item>
/// <item><description><see cref="OpenTelemetryAgent"/> — OpenTelemetry instrumentation following semantic conventions for generative AI. Disable with <see cref="HarnessAgentOptions.DisableOpenTelemetry"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// The underlying <see cref="ChatClientAgent"/> is configured with
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
/// to match the manually-assembled pipeline.
/// <strong>Default tools:</strong>
/// <list type="bullet">
/// <item><description><see cref="HostedWebSearchTool"/> — a hosted web search tool added to chat options by default. Disable with <see cref="HarnessAgentOptions.DisableWebSearch"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied, the agent defaults to an
/// <see cref="InMemoryChatHistoryProvider"/> whose chat reducer applies the same compaction strategy,
/// keeping in-memory history from growing unboundedly across sessions.
/// <strong>Chat history:</strong> When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied,
/// the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>. If compaction is enabled, the provider
/// is configured with a compaction-based chat reducer to keep in-memory history bounded. Otherwise, no reducer
/// is applied.
/// </para>
/// <para>
/// <strong>Default instructions:</strong> The agent includes built-in system instructions (<see cref="DefaultInstructions"/>)
/// that guide general tool usage and reasoning patterns. These can be overridden via <see cref="HarnessAgentOptions.HarnessInstructions"/>
/// and combined with agent-specific instructions via <see cref="ChatOptions.Instructions"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
@@ -90,21 +105,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// </summary>
/// <param name="chatClient">
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
/// The agent wraps this client in a function-invocation, per-service-call persistence,
/// and compaction pipeline automatically.
/// </param>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// Used to configure the compaction strategy and to limit the model's output.
/// The agent wraps this client in a function-invocation and per-service-call persistence pipeline.
/// When compaction is enabled via <paramref name="options"/>, a compaction decorator is also added.
/// </param>
/// <param name="options">
/// Optional configuration options for the agent, including instructions override, tools,
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// additional context providers, chat history provider, and compaction settings.
/// When <see langword="null"/>, the agent uses built-in default settings with compaction disabled.
/// </param>
/// <param name="loggerFactory">
/// Optional logger factory for creating loggers used by the agent and its components.
@@ -116,23 +123,22 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// <paramref name="chatClient"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> is not positive, or
/// <see cref="HarnessAgentOptions.MaxOutputTokens"/> is negative or greater than or equal to
/// <see cref="HarnessAgentOptions.MaxContextWindowTokens"/> (when both are provided).
/// </exception>
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
public HarnessAgent(IChatClient chatClient, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
: base(BuildAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
options,
loggerFactory,
services))
{
}
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
private static AIAgent BuildAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
{
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, options, loggerFactory, services);
AIAgentBuilder builder = innerAgent.AsBuilder();
@@ -149,17 +155,35 @@ public sealed class HarnessAgent : DelegatingAIAgent
return builder.Build(services);
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: maxContextWindowTokens,
maxOutputTokens: maxOutputTokens);
// Determine compaction strategy:
// 1. DisableCompaction = true → no compaction
// 2. Custom CompactionStrategy provided → use it (ignore token params)
// 3. Both token params provided → build default ContextWindowCompactionStrategy
// 4. Otherwise → no compaction
CompactionStrategy? compactionStrategy = null;
if (options?.DisableCompaction is not true)
{
if (options?.CompactionStrategy is CompactionStrategy customStrategy)
{
compactionStrategy = customStrategy;
}
else if (options?.MaxContextWindowTokens is int maxCtx && options?.MaxOutputTokens is int maxOut)
{
compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: maxCtx,
maxOutputTokens: maxOut);
}
}
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
?? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
});
?? (compactionStrategy is not null
? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
})
: new InMemoryChatHistoryProvider());
string harnessInstructions = options?.HarnessInstructions ?? DefaultInstructions;
string? agentInstructions = options?.ChatOptions?.Instructions;
@@ -172,9 +196,11 @@ public sealed class HarnessAgent : DelegatingAIAgent
(false, false) => $"{harnessInstructions}\n\n{agentInstructions}",
};
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
ChatOptions chatOptions = BuildChatOptions(options, instructions, options?.MaxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
CompactionProvider? compactionProvider = compactionStrategy is not null
? new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory)
: null;
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
@@ -185,13 +211,19 @@ public sealed class HarnessAgent : DelegatingAIAgent
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
}
return chatClientBuilder
ChatClientBuilder pipeline = chatClientBuilder
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseMessageInjection()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(compactionProvider)
.UsePerServiceCallChatHistoryPersistence();
if (compactionProvider is not null)
{
pipeline = pipeline.UseAIContextProviders(compactionProvider);
}
return pipeline
.BuildAIAgent(new ChatClientAgentOptions
{
Id = options?.Id,
@@ -209,11 +241,15 @@ public sealed class HarnessAgent : DelegatingAIAgent
services);
}
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int? maxOutputTokens)
{
ChatOptions result = options?.ChatOptions?.Clone() ?? new ChatOptions();
result.Instructions = instructions;
result.MaxOutputTokens ??= maxOutputTokens;
if (maxOutputTokens.HasValue)
{
result.MaxOutputTokens ??= maxOutputTokens.Value;
}
if (options?.DisableWebSearch is not true)
{
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Compaction;
#if NET
using Microsoft.Agents.AI.Tools.Shell;
#endif
@@ -31,6 +32,68 @@ public sealed class HarnessAgentOptions
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// </summary>
/// <remarks>
/// <para>
/// When both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are provided (and no
/// custom <see cref="CompactionStrategy"/> is set), a default <see cref="ContextWindowCompactionStrategy"/>
/// is constructed from these values to prevent function-invocation loops from overflowing the context window.
/// </para>
/// <para>
/// Ignored when <see cref="CompactionStrategy"/> is provided or when <see cref="DisableCompaction"/> is
/// <see langword="true"/>.
/// </para>
/// </remarks>
public int? MaxContextWindowTokens { get; set; }
/// <summary>
/// Gets or sets the maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// </summary>
/// <remarks>
/// <para>
/// When set, this value is used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/>
/// when not explicitly configured.
/// </para>
/// <para>
/// For compaction purposes, this value is used together with <see cref="MaxContextWindowTokens"/> to construct a
/// default <see cref="ContextWindowCompactionStrategy"/> — but only when no custom <see cref="CompactionStrategy"/>
/// is provided and <see cref="DisableCompaction"/> is <see langword="false"/>.
/// </para>
/// </remarks>
public int? MaxOutputTokens { get; set; }
/// <summary>
/// Gets or sets a custom <see cref="Compaction.CompactionStrategy"/> to use for in-loop context-window compaction.
/// </summary>
/// <remarks>
/// <para>
/// When provided, this strategy is used directly and <see cref="MaxContextWindowTokens"/> and
/// <see cref="MaxOutputTokens"/> are ignored for compaction purposes (<see cref="MaxOutputTokens"/> is still
/// used as the default for <see cref="ChatOptions"/>.<see cref="ChatOptions.MaxOutputTokens"/> if set).
/// </para>
/// <para>
/// When <see langword="null"/> and both <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/>
/// are provided, a default <see cref="ContextWindowCompactionStrategy"/> is constructed from those values.
/// </para>
/// <para>
/// This property is ignored when <see cref="DisableCompaction"/> is <see langword="true"/>.
/// </para>
/// </remarks>
public CompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether in-loop compaction is disabled.
/// </summary>
/// <remarks>
/// When <see langword="true"/>, compaction is disabled regardless of <see cref="CompactionStrategy"/>,
/// <see cref="MaxContextWindowTokens"/>, or <see cref="MaxOutputTokens"/> settings. No
/// <see cref="CompactionProvider"/> is added to the chat client pipeline, and the default
/// <see cref="InMemoryChatHistoryProvider"/> is configured without a chat reducer.
/// </remarks>
public bool DisableCompaction { get; set; }
/// <summary>
/// Gets or sets additional chat options such as tools for the agent to use.
/// </summary>
@@ -68,9 +131,9 @@ public sealed class HarnessAgentOptions
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>
/// configured with a compaction-based chat reducer derived from the <c>maxContextWindowTokens</c>
/// and <c>maxOutputTokens</c> constructor parameters of <see cref="HarnessAgent"/>.
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>.
/// If <see cref="MaxContextWindowTokens"/> and <see cref="MaxOutputTokens"/> are both provided,
/// the default provider is configured with a compaction-based chat reducer; otherwise, no reducer is applied.
/// </remarks>
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
@@ -1,10 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Agents.AI.Purview.Models.Jobs;
using Microsoft.Agents.AI.Purview.Models.Requests;
using Microsoft.Agents.AI.Purview.Models.Responses;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Purview;
@@ -16,6 +20,7 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
{
private readonly IChannelHandler _channelHandler;
private readonly IPurviewClient _purviewClient;
private readonly ICacheProvider _cacheProvider;
private readonly ILogger _logger;
/// <summary>
@@ -23,12 +28,14 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
/// </summary>
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
/// <param name="cacheProvider">The cache provider used to store protection scopes results.</param>
/// <param name="logger">The logger used to log information about background jobs.</param>
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
{
this._channelHandler = channelHandler;
this._purviewClient = purviewClient;
this._cacheProvider = cacheProvider;
this._logger = logger;
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
@@ -67,6 +74,28 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
break;
case ContentActivityJob contentActivityJob:
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
break;
case ScopeRetrievalJob scopeRetrievalJob:
try
{
ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false);
await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false);
(bool shouldProcess, List<DlpActionInfo> _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response);
if (!shouldProcess)
{
ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest;
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
}
}
catch (PurviewPaymentRequiredException ex)
{
await this._cacheProvider.SetAsync(
new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId),
new PaymentRequiredCacheEntry(ex.Message),
CancellationToken.None).ConfigureAwait(false);
}
break;
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// Cached tenant-level payment required state.
/// </summary>
internal sealed class PaymentRequiredCacheEntry
{
/// <summary>
/// Creates a new instance of <see cref="PaymentRequiredCacheEntry"/>.
/// </summary>
/// <param name="message">The payment required error message.</param>
public PaymentRequiredCacheEntry(string? message)
{
this.Message = message;
}
/// <summary>
/// The payment required error message.
/// </summary>
public string? Message { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Purview.Models.Common;
/// <summary>
/// A cache key for tenant-level payment required state.
/// </summary>
internal sealed class PaymentRequiredCacheKey
{
/// <summary>
/// Creates a new instance of <see cref="PaymentRequiredCacheKey"/>.
/// </summary>
/// <param name="tenantId">The id of the tenant.</param>
public PaymentRequiredCacheKey(string tenantId)
{
this.TenantId = tenantId;
}
/// <summary>
/// The id of the tenant.
/// </summary>
public string TenantId { get; set; }
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Agents.AI.Purview.Models.Requests;
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
/// <summary>
/// Class representing a job that refreshes the protection scopes cache in the background.
/// </summary>
/// <remarks>
/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
/// foreground ProcessContent call.
/// </remarks>
internal sealed class ScopeRetrievalJob : BackgroundJobBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ScopeRetrievalJob"/> class.
/// </summary>
/// <param name="request">The protection scopes request to send to Purview.</param>
/// <param name="cacheKey">The cache key used to store the response.</param>
/// <param name="processContentRequest">The original process content request that triggered scope retrieval.</param>
public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest)
{
this.Request = request;
this.CacheKey = cacheKey;
this.ProcessContentRequest = processContentRequest;
}
/// <summary>
/// Gets the protection scopes request.
/// </summary>
public ProtectionScopesRequest Request { get; }
/// <summary>
/// Gets the cache key used to store the response.
/// </summary>
public ProtectionScopesCacheKey CacheKey { get; }
/// <summary>
/// Gets the original process content request that triggered scope retrieval.
/// </summary>
public ProcessContentRequest ProcessContentRequest { get; }
}
@@ -53,4 +53,10 @@ internal sealed class ProcessContentRequest
/// </summary>
[JsonIgnore]
internal string? ScopeIdentifier { get; set; }
/// <summary>
/// Indicates whether the ProcessContent request should ask the service for inline evaluation.
/// </summary>
[JsonIgnore]
internal bool ProcessInline { get; set; }
}
@@ -130,6 +130,11 @@ internal sealed class PurviewClient : IPurviewClient
message.Headers.Add("If-None-Match", request.ScopeIdentifier);
}
if (request.ProcessInline)
{
message.Headers.Add("Prefer", "evaluateInline");
}
string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
message.Content = new StringContent(content, Encoding.UTF8, "application/json");
@@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe
The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait.
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit.
## Exceptions
| Exception | Scenario |
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Purview.Models.Common;
@@ -193,43 +194,60 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
{
ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
new PaymentRequiredCacheKey(pcRequest.TenantId),
cancellationToken).ConfigureAwait(false);
if (cachedPaymentRequired != null)
{
throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required");
}
ProtectionScopesCacheKey cacheKey = new(psRequest);
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(cacheKey, cancellationToken).ConfigureAwait(false);
ProtectionScopesResponse psResponse;
if (cacheResponse != null)
{
psResponse = cacheResponse;
}
else
{
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
}
try
{
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
}
catch (PurviewJobException)
{
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
}
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately.
/// </summary>
private async Task<ProcessContentResponse> ProcessWithCachedScopesAsync(
ProcessContentRequest pcRequest,
ProtectionScopesResponse psResponse,
ProtectionScopesCacheKey cacheKey,
CancellationToken cancellationToken)
{
pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
(bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
if (shouldProcess)
{
pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline;
if (executionMode == ExecutionMode.EvaluateOffline)
{
this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
return new ProcessContentResponse();
}
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
{
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
return pcResponse;
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
}
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
@@ -238,6 +256,30 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
return new ProcessContentResponse();
}
/// <summary>
/// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale.
/// </summary>
private async Task<ProcessContentResponse> CallProcessContentAsync(
ProcessContentRequest pcRequest,
ProtectionScopesCacheKey cacheKey,
List<DlpActionInfo>? dlpActions,
CancellationToken cancellationToken)
{
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
{
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
if (dlpActions?.Count > 0)
{
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
}
return pcResponse;
}
/// <summary>
/// Dedupe policy actions received from the service.
/// </summary>
@@ -248,9 +290,21 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
{
if (actionInfos?.Count > 0)
{
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
actionInfos :
[.. pcResponse.PolicyActions, .. actionInfos];
List<DlpActionInfo> combinedActions = [];
HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = [];
IEnumerable<DlpActionInfo> allActions = pcResponse.PolicyActions is null
? actionInfos
: pcResponse.PolicyActions.Concat(actionInfos);
foreach (DlpActionInfo actionInfo in allActions)
{
if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction)))
{
combinedActions.Add(actionInfo);
}
}
pcResponse.PolicyActions = combinedActions;
}
return pcResponse;
@@ -262,7 +316,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
/// <param name="pcRequest">The process content request.</param>
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
/// <returns>A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.</returns>
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
{
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
@@ -284,7 +338,11 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
{
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
{
locationMatch = true;
break;
}
}
if (activityMatch && locationMatch)
@@ -18,6 +18,8 @@ namespace Microsoft.Agents.AI.Purview.Serialization;
[JsonSerializable(typeof(ContentActivitiesRequest))]
[JsonSerializable(typeof(ContentActivitiesResponse))]
[JsonSerializable(typeof(ProtectionScopesCacheKey))]
[JsonSerializable(typeof(PaymentRequiredCacheKey))]
[JsonSerializable(typeof(PaymentRequiredCacheEntry))]
internal sealed partial class SourceGenerationContext : JsonSerializerContext;
/// <summary>
@@ -2,10 +2,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -39,7 +39,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
private readonly SemaphoreSlim _clientLock = new(1, 1);
@@ -66,16 +66,15 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
string? connectionName,
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
if (IsListToolsToolName(toolName))
{
ThrowIfListToolsArgumentsSpecified(arguments);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
}
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
@@ -145,10 +144,11 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken)
{
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
string trimmedUrl = serverUrl.Trim();
var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers);
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -158,7 +158,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return existingClient;
}
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
this._clients[clientCacheKey] = newClient;
return newClient;
}
@@ -168,6 +168,19 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
}
}
/// <summary>
/// Builds the per-client cache key as a 4-tuple of
/// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components
/// participate so that callers using different labels/connections/headers receive
/// distinct <see cref="McpClient"/> instances even when targeting the same URL.
/// </summary>
internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
string trimmedUrl,
string? serverLabel,
string? connectionName,
IDictionary<string, string>? headers) =>
(trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
private async Task<McpClient> CreateClientAsync(
string serverUrl,
string? serverLabel,
@@ -185,7 +198,12 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
{
httpClient = new HttpClient();
// Disable cookies so handler-level state (cookie jar) cannot cross the cache-key
// isolation boundary established by GetOrCreateClientAsync. The actual MCP auth
// travels via AdditionalHeaders (set per-transport below), not session cookies.
// CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler.
HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true };
httpClient = new HttpClient(handler);
this._ownedHttpClients[httpClientCacheKey] = httpClient;
}
@@ -202,26 +220,50 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
/// <summary>
/// Computes a deterministic, order-independent hash of the header set.
/// Header names are lower-cased for case-insensitive matching (RFC 7230 §3.2).
/// Header values remain case-sensitive (RFC 7235 — credentials are case-sensitive).
/// </summary>
#pragma warning disable CA1308 // RFC 7230 §3.2 requires lower-cased header names for case-insensitive comparison; CA1308's uppercase preference does not apply here
internal static string ComputeHeadersHash(IDictionary<string, string>? headers)
{
if (headers is null || headers.Count == 0)
{
return string.Empty;
}
// Build a deterministic, sorted representation of the headers
// Within a single process lifetime, the hashcodes are consistent.
// This will ensure that the same set of headers always produces the same hash, regardless of order.
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
int hashCode = 17;
foreach (KeyValuePair<string, string> kvp in sorted)
// Sort by lower-cased key for deterministic ordering, preserving value case.
SortedDictionary<string, string> sorted = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, string> header in headers)
{
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
sorted[header.Key.ToLowerInvariant()] = header.Value;
}
return hashCode.ToString(CultureInfo.InvariantCulture);
StringBuilder payload = new();
foreach (KeyValuePair<string, string> kvp in sorted)
{
payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
}
byte[] inputBytes = Encoding.UTF8.GetBytes(payload.ToString());
#if NET5_0_OR_GREATER
byte[] hashBytes = SHA256.HashData(inputBytes);
#else
using SHA256 sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(inputBytes);
#endif
// Convert to hex string (compatible with net472/netstandard2.0)
StringBuilder hex = new(hashBytes.Length * 2);
foreach (byte b in hashBytes)
{
hex.Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
}
return hex.ToString();
}
#pragma warning restore CA1308
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
{
@@ -13,6 +13,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -27,6 +28,13 @@ internal sealed class InvokeFunctionToolExecutor(
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
{
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
/// <summary>
/// Snapshot of evaluated parameters at approval-request time.
/// </summary>
private ApprovalSnapshot? _approvalSnapshot;
/// <summary>
/// Step identifiers for the function tool invocation workflow.
/// </summary>
@@ -69,6 +77,10 @@ internal sealed class InvokeFunctionToolExecutor(
// If approval is required, add user input request content
if (requireApproval)
{
// Snapshot the evaluated parameters.
// If state mutates during the approval window, the approved values are used on resume.
this._approvalSnapshot = new ApprovalSnapshot(functionName, arguments);
requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall));
}
@@ -155,6 +167,31 @@ internal sealed class InvokeFunctionToolExecutor(
// Completes the action after processing the function result.
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
// Clear the approval snapshot after the action completes so a subsequent
// execution of the same executor instance doesn't reuse stale data.
this._approvalSnapshot = null;
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
/// <remarks>
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
/// </remarks>
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
/// <remarks>
/// Restores the approval snapshot from workflow state after a checkpoint restore.
/// </remarks>
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -262,7 +299,24 @@ internal sealed class InvokeFunctionToolExecutor(
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
{
string functionName = this.GetFunctionName();
string functionName;
Dictionary<string, object?>? arguments;
if (this._approvalSnapshot is { } snapshot)
{
// Use the snapshot captured at approval-request time so we invoke exactly what
// the user approved, even if Power Fx state has mutated during the approval window.
functionName = snapshot.FunctionName;
arguments = snapshot.Arguments;
}
else
{
// Fallback for checkpoints created before approval snapshots were introduced.
this.Logger.LogWarning("Approval snapshot missing for '{ActionId}'; falling back to expression re-evaluation.", this.Id);
functionName = this.GetFunctionName();
arguments = this.GetArguments();
}
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
@@ -275,8 +329,7 @@ internal sealed class InvokeFunctionToolExecutor(
};
}
Dictionary<string, object?>? arguments = this.GetArguments();
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues());
object? result;
try
@@ -341,4 +394,13 @@ internal sealed class InvokeFunctionToolExecutor(
return result;
}
/// <summary>
/// Stores the evaluated parameters at approval-request time so that
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
/// </summary>
internal sealed record ApprovalSnapshot(
string FunctionName,
Dictionary<string, object?>? Arguments);
}
@@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal static class MagenticConstants
{
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
public const string CurrentSpeakerStateKey = nameof(CurrentSpeakerStateKey);
}
@@ -90,6 +90,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
private MagenticTaskContext? _taskContext;
private PortBinding? _planReviewPort;
private string? _currentSpeakerExecutorId;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
@@ -196,15 +197,46 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
else
{
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan).
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
if (messages is { Count: > 0 })
{
// Capture the participant's reply into the manager-visible chat history so the progress ledger can see it.
this._taskContext.ChatHistory.AddRange(messages);
// Share the reply with the other participants except the replier
await this.BroadcastReplyToOtherParticipantsAsync(messages, context, cancellationToken).ConfigureAwait(false);
}
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Forwards a participant's reply to every other participant so they share the running conversation.
/// The messages are buffered (no <see cref="TurnToken"/> is sent) - they only become context for the participant's next turn.
/// </summary>
private ValueTask BroadcastReplyToOtherParticipantsAsync(
List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
// Without a known current speaker we cannot exclude the reply's author, so skip the broadcast
// rather than risk echoing the reply back to its own author. This covers the window after a
// checkpoint restore but before any delegation has set the current speaker.
if (string.IsNullOrEmpty(this._currentSpeakerExecutorId))
{
return default;
}
List<Task>? sendTasks = null;
foreach (AIAgent agent in team)
{
string executorId = AIAgentHostExecutor.IdFor(agent);
if (string.Equals(executorId, this._currentSpeakerExecutorId, StringComparison.Ordinal))
{
continue;
}
(sendTasks ??= []).Add(context.SendMessageAsync(messages, executorId, cancellationToken).AsTask());
}
return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks));
}
private ChatMessage? _fullTaskLedgerMessage;
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
@@ -287,15 +319,18 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
return;
}
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
{
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
taskContext.ChatHistory.Add(instruction);
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
// Target the instruction at the chosen speaker only.
await context.SendMessageAsync(instruction, nextExecutorId, cancellationToken).ConfigureAwait(false);
}
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
this._currentSpeakerExecutorId = nextExecutorId;
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
}
@@ -303,6 +338,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
{
bool wasStalled = taskContext.IsStalled;
taskContext.Reset();
this._currentSpeakerExecutorId = null;
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
@@ -313,9 +349,9 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
taskContext.IsTerminated = true;
this._currentSpeakerExecutorId = null;
}
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task contextStateTask = this._taskContext == null
@@ -325,14 +361,21 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
cancellationToken: cancellationToken)
.AsTask();
Task currentSpeakerTask = context.QueueStateUpdateAsync(MagenticConstants.CurrentSpeakerStateKey,
this._currentSpeakerExecutorId,
cancellationToken: cancellationToken)
.AsTask();
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
contextStateTask).ConfigureAwait(false);
contextStateTask,
currentSpeakerTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
.ConfigureAwait(false);
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(),
LoadContextStateAsync(),
LoadCurrentSpeakerAsync()).ConfigureAwait(false);
async Task LoadContextStateAsync()
{
@@ -344,5 +387,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
this._taskContext = new MagenticTaskContext(state, team, limits, []);
}
}
async Task LoadCurrentSpeakerAsync()
{
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(MagenticConstants.CurrentSpeakerStateKey, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
}
}
@@ -38,6 +38,8 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public sealed partial class ChatClientAgent : AIAgent
{
private const string AGUIProviderName = "ag-ui";
private readonly ChatClientAgentOptions? _agentOptions;
private readonly HashSet<string> _aiContextProviderStateKeys;
private readonly AIAgentMetadata _agentMetadata;
@@ -562,6 +564,7 @@ public sealed partial class ChatClientAgent : AIAgent
requestChatOptions.ModelId ??= this._agentOptions.ChatOptions.ModelId;
requestChatOptions.PresencePenalty ??= this._agentOptions.ChatOptions.PresencePenalty;
requestChatOptions.ResponseFormat ??= this._agentOptions.ChatOptions.ResponseFormat;
requestChatOptions.Reasoning ??= this._agentOptions.ChatOptions.Reasoning;
requestChatOptions.Seed ??= this._agentOptions.ChatOptions.Seed;
requestChatOptions.Temperature ??= this._agentOptions.ChatOptions.Temperature;
requestChatOptions.TopP ??= this._agentOptions.ChatOptions.TopP;
@@ -815,7 +818,7 @@ public sealed partial class ChatClientAgent : AIAgent
if (!string.IsNullOrWhiteSpace(responseConversationId))
{
if (this._agentOptions?.ChatHistoryProvider is not null)
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) && this._agentOptions?.ChatHistoryProvider is not null)
{
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
@@ -929,6 +932,9 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
private static bool IsAGUIProviderName(string? providerName) =>
string.Equals(providerName, AGUIProviderName, StringComparison.Ordinal);
/// <summary>
/// Ensures that <see cref="AIAgent.CurrentRunContext"/> contains the resolved session.
/// </summary>
@@ -976,12 +982,17 @@ public sealed partial class ChatClientAgent : AIAgent
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
{
ChatHistoryProvider? provider = chatOptions?.ConversationId is null ? this.ChatHistoryProvider : null;
ChatHistoryProvider? provider =
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
? this.ChatHistoryProvider
: null;
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
{
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
{
throw new InvalidOperationException(
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
@@ -54,7 +54,6 @@ builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
app.MapGet("/readiness", () => Results.Ok());
app.Run();
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
@@ -243,6 +243,46 @@ public sealed class AGUIAgentTests
Assert.Contains(updates, u => u.Text == "Hello");
}
[Fact]
public async Task RunStreamingAsync_WithSession_SendsFullHistoryAfterThreadIdIsSetAsync()
{
// Arrange
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "First response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg2", Delta = "Second response" },
new TextMessageEndEvent { MessageId = "msg2" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
AgentSession session = await agent.CreateSessionAsync();
// Act
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session))
{
}
await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session))
{
}
// Assert
Assert.Equal([1, 3], captureHandler.CapturedMessageCounts);
}
[Fact]
public async Task DeserializeSession_WithValidState_ReturnsChatClientAgentSessionAsync()
{
@@ -1686,10 +1726,12 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
{
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
private readonly List<int> _capturedMessageCounts = [];
public bool RequestWasMade { get; private set; }
public JsonElement? CapturedState { get; private set; }
public int CapturedMessageCount { get; private set; }
public IReadOnlyList<int> CapturedMessageCounts => this._capturedMessageCounts;
public void AddResponse(BaseEvent[] events)
{
@@ -1714,6 +1756,7 @@ internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
this.CapturedState = input.State;
}
this.CapturedMessageCount = input.Messages.Count();
this._capturedMessageCounts.Add(this.CapturedMessageCount);
}
if (this._responseFactories.Count == 0)
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// xUnit collection that serializes tests mutating the <c>FOUNDRY_PROJECT_ENDPOINT</c>
/// process environment variable. Without this, parallel test execution causes flaky
/// races between tests that set / unset the variable.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class FoundryProjectEndpointEnvFixture
{
public const string Name = "FoundryProjectEndpointEnv";
}
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -51,28 +54,144 @@ public class FoundryToolboxBearerTokenHandlerTests
}
[Fact]
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
public async Task SendAsync_UsesAiAzureComScopeAsync()
{
// Arrange
var capturedContexts = new List<TokenRequestContext>();
var credential = new Mock<TokenCredential>();
credential
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
var (handler, _) = CreateHandlerPair(credential);
using var invoker = new HttpMessageInvoker(handler);
// Act
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
await invoker.SendAsync(request, CancellationToken.None);
// Assert: spec §4 mandates the https://ai.azure.com audience.
Assert.Single(capturedContexts);
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
}
[Fact]
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
{
// Arrange
var (handler, _) = CreateHandlerPair(featuresHeader: null);
using var invoker = new HttpMessageInvoker(handler);
// Act
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
Assert.Equal("Toolboxes=V1Preview", values.Single());
}
[Fact]
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
{
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
await invoker.SendAsync(request, CancellationToken.None);
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
Assert.Contains("feature1,feature2", values);
var header = values.Single();
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
Assert.Contains("feature1", header, StringComparison.Ordinal);
Assert.Contains("feature2", header, StringComparison.Ordinal);
}
[Fact]
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
{
var (handler, _) = CreateHandlerPair(featuresHeader: null);
// Override already contains the mandatory flag — must not be duplicated in the merged value.
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
using var response = await invoker.SendAsync(request, CancellationToken.None);
await invoker.SendAsync(request, CancellationToken.None);
Assert.False(request.Headers.Contains("Foundry-Features"));
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
var header = values.Single();
var count = 0;
var idx = 0;
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
{
count++;
idx += "Toolboxes=V1Preview".Length;
}
Assert.Equal(1, count);
}
[Fact]
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
{
// Arrange: activate an Activity so Activity.Current is populated.
using var listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
};
ActivitySource.AddActivityListener(listener);
using var source = new ActivitySource("test-source");
using var activity = source.StartActivity("test-op")!;
Assert.NotNull(activity);
activity.TraceStateString = "vendor=value";
activity.AddBaggage("user", "alice");
var (handler, _) = CreateHandlerPair();
using var invoker = new HttpMessageInvoker(handler);
// Act
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
await invoker.SendAsync(request, CancellationToken.None);
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
Assert.Equal("vendor=value", tsValues.Single());
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
}
[Fact]
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
{
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
using var listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
};
ActivitySource.AddActivityListener(listener);
using var source = new ActivitySource("test-source");
using var activity = source.StartActivity("test-op")!;
Assert.NotNull(activity);
var (handler, _) = CreateHandlerPair();
using var invoker = new HttpMessageInvoker(handler);
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
// Act
await invoker.SendAsync(request, CancellationToken.None);
// Assert
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
var list = values.ToList();
Assert.Single(list);
Assert.Equal(PresetTraceparent, list[0]);
}
[Theory]
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
[Collection(FoundryProjectEndpointEnvFixture.Name)]
public class FoundryToolboxHealthCheckTests
{
[Fact]
public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync()
{
// Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports
// Pending. The health check must surface that as the registration's failure
// status so the platform waits before sending traffic.
var service = CreateServiceWithoutStarting();
var check = new FoundryToolboxHealthCheck(service);
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
// Act
var result = await check.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync()
{
// Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev.
// The container must still pass readiness because the rest of the agent is functional.
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
try
{
var service = CreateServiceWithoutStarting(toolbox: "any");
await service.StartAsync(CancellationToken.None);
var check = new FoundryToolboxHealthCheck(service);
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
// Act
var result = await check.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
}
}
[Fact]
public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync()
{
// Arrange: pre-registered toolbox at an unreachable endpoint forces StartAsync to
// record the failure. The health-check must reflect Unhealthy and expose the
// failed toolbox names in the result data so operators can diagnose without log
// diving.
var options = new FoundryToolboxOptions
{
EndpointOverride = "http://127.0.0.1:1/unreachable",
};
options.ToolboxNames.Add("broken-toolbox");
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
var check = new FoundryToolboxHealthCheck(service);
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
// Act
var result = await check.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.True(result.Data.ContainsKey("failedToolboxes"));
var failed = Assert.IsAssignableFrom<IReadOnlyList<string>>(result.Data["failedToolboxes"]);
Assert.Equal("broken-toolbox", Assert.Single(failed));
}
[Fact]
public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync()
{
// Arrange: an endpoint set but no pre-registered toolboxes is the legitimate
// lazy-only setup. StartAsync reports Healthy and the check must agree.
var options = new FoundryToolboxOptions
{
EndpointOverride = "http://127.0.0.1:1/unused",
};
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
var check = new FoundryToolboxHealthCheck(service);
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
// Act
var result = await check.CheckHealthAsync(context);
// Assert
Assert.Equal(HealthStatus.Healthy, result.Status);
}
private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null)
{
var options = new FoundryToolboxOptions();
if (toolbox is not null)
{
options.ToolboxNames.Add(toolbox);
}
return new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
}
private static HealthCheckContext NewContext(HealthStatus failureStatus) =>
new()
{
Registration = new HealthCheckRegistration(
name: "foundry-toolbox",
instance: Mock.Of<IHealthCheck>(),
failureStatus: failureStatus,
tags: null),
};
}
@@ -9,6 +9,7 @@ using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
[Collection(FoundryProjectEndpointEnvFixture.Name)]
public class FoundryToolboxServiceTests
{
[Fact]
@@ -39,15 +40,17 @@ public class FoundryToolboxServiceTests
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
{
// Ensure env var is not set (tests may run in any CI environment)
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
// Ensure neither env var is set (tests may run in any CI environment)
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
try
{
var options = new FoundryToolboxOptions();
@@ -59,10 +62,157 @@ public class FoundryToolboxServiceTests
await service.StartAsync(CancellationToken.None);
Assert.Empty(service.Tools);
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
Assert.Empty(service.FailedToolboxNames);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
}
}
[Fact]
public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync()
{
// Arrange: point the service at an unreachable host and confirm StartAsync
// attempts to open the pre-registered toolbox (verified via FailedToolboxNames
// recording the attempted name and StartupStatus reflecting the failure).
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
Environment.SetEnvironmentVariable(
"FOUNDRY_PROJECT_ENDPOINT",
"https://example.invalid/api/projects/proj");
try
{
var options = new FoundryToolboxOptions { ApiVersion = "v1" };
options.ToolboxNames.Add("my-toolbox");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Act: StartAsync attempts to connect to the invalid endpoint and fails.
// The failure path records FailedToolboxNames; the value confirms the resolver ran.
await service.StartAsync(CancellationToken.None);
// Assert: open failed, status reflects that (resolver was reached), and
// the failed name matches — i.e. we attempted the right toolbox.
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
Assert.Single(service.FailedToolboxNames);
Assert.Equal("my-toolbox", service.FailedToolboxNames[0]);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
}
}
[Fact]
public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync()
{
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
Environment.SetEnvironmentVariable(
"FOUNDRY_PROJECT_ENDPOINT",
"https://example.invalid/api/projects/proj/");
try
{
var options = new FoundryToolboxOptions();
options.ToolboxNames.Add("tb");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
// Arrange/Act: when trailing-slash normalization works the open still fails
// (host is unreachable), but FailedToolboxNames records the attempted name —
// proof that the resolver did not throw on the slash and the URL was built.
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
Assert.Single(service.FailedToolboxNames);
Assert.Equal("tb", service.FailedToolboxNames[0]);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
}
}
[Fact]
public async Task StartAsync_EndpointOverrideWinsOverEnvAsync()
{
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
Environment.SetEnvironmentVariable(
"FOUNDRY_PROJECT_ENDPOINT",
"https://from-env.invalid/api/projects/proj");
try
{
// EndpointOverride should take precedence over the env var.
var options = new FoundryToolboxOptions
{
EndpointOverride = "http://127.0.0.1:1/from-override",
};
options.ToolboxNames.Add("tb");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
// Override URL is unreachable; we expect Unhealthy (proving Start did try to open
// a toolbox, i.e. did not fall into the NoEndpoint branch).
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
Assert.Single(service.FailedToolboxNames);
}
finally
{
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
}
}
[Fact]
public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync()
{
// Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync
// to throw inside the catch-and-log path. The service must still complete StartAsync
// (so the host doesn't crash) and surface the failure via StartupStatus.
var options = new FoundryToolboxOptions
{
EndpointOverride = "http://127.0.0.1:1/unreachable",
};
options.ToolboxNames.Add("broken-toolbox");
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
// Act
await service.StartAsync(CancellationToken.None);
// Assert
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
Assert.Single(service.FailedToolboxNames);
Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]);
Assert.Empty(service.Tools);
}
[Fact]
public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync()
{
// No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check
// should report Healthy so the readiness probe passes.
var options = new FoundryToolboxOptions
{
EndpointOverride = "http://127.0.0.1:1/unused",
};
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of<TokenCredential>());
await service.StartAsync(CancellationToken.None);
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
Assert.Empty(service.FailedToolboxNames);
Assert.Empty(service.Tools);
}
}
@@ -2,9 +2,15 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;
using OpenAI.Responses;
@@ -135,4 +141,73 @@ public class ServiceCollectionExtensionsTests
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
}
// ── /readiness auto-mapping (Foundry container-image-spec §2) ────────────────
[Fact]
public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync()
{
// Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does
// NOT map /readiness in this case, so MapFoundryResponses must cover the gap.
using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses());
// Act
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync()
{
// Arrange: developer already mapped /readiness with a custom body. The auto-map
// must detect the existing route and leave it untouched (no AmbiguousMatchException
// at runtime, no override of the developer's response).
const string CustomBody = "ready-from-developer";
using var host = await BuildTestHostAsync(static app =>
{
app.MapGet("/readiness", () => Results.Text("ready-from-developer"));
app.MapFoundryResponses();
});
// Act
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(CustomBody, body);
}
[Fact]
public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync()
{
// Arrange: defensive coverage for callers that map the responses pipeline twice
// (e.g. once at the root and once under "openai/v1" in the existing AF samples).
using var host = await BuildTestHostAsync(static app =>
{
app.MapFoundryResponses();
app.MapFoundryResponses("openai/v1");
});
// Act + Assert: a single GET /readiness must succeed without ambiguous-match throw.
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static async Task<IHost> BuildTestHostAsync(Action<WebApplication> configure)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockAgent = new Mock<AIAgent>();
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
builder.Services.AddFoundryResponses(mockAgent.Object);
var app = builder.Build();
configure(app);
await app.StartAsync();
return app;
}
}
@@ -10,57 +10,86 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
[Trait("Category", "Integration")]
public class GitHubCopilotAgentTests
{
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
private static void SkipIfCopilotNotConfigured()
{
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("COPILOT_GITHUB_TOKEN")))
{
Assert.Skip("COPILOT_GITHUB_TOKEN not set; skipping GitHub Copilot integration tests.");
}
}
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(PermissionDecision.ApproveOnce());
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
AgentSession session = await agent.CreateSessionAsync();
// Act
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.");
try
{
// Act
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.", session);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.Contains("4", response.Text);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.Contains("4", response.Text);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunStreamingAsync_WithSimplePrompt_ReturnsUpdatesAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
AgentSession session = await agent.CreateSessionAsync();
// Act
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number."))
try
{
updates.Add(update);
}
// Act
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.", session))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
string fullText = string.Join("", updates.Select(u => u.Text));
Assert.Contains("4", fullText);
// Assert
Assert.NotEmpty(updates);
string fullText = string.Join("", updates.Select(u => u.Text));
Assert.Contains("4", fullText);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithFunctionTool_InvokesToolAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
bool toolInvoked = false;
AIFunction weatherTool = AIFunctionFactory.Create((string location) =>
@@ -72,24 +101,42 @@ public class GitHubCopilotAgentTests
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
await using GitHubCopilotAgent agent = new(
client,
tools: [weatherTool],
instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions.");
SessionConfig sessionConfig = new()
{
Tools = [weatherTool],
OnPermissionRequest = OnPermissionRequestAsync,
SystemMessage = new SystemMessageConfig
{
Mode = SystemMessageMode.Append,
Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.",
},
};
// Act
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?");
await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.True(toolInvoked);
try
{
// Act
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.True(toolInvoked);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithSession_MaintainsContextAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
@@ -99,23 +146,32 @@ public class GitHubCopilotAgentTests
AgentSession session = await agent.CreateSessionAsync();
// Act - First turn
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
Assert.NotNull(response1);
try
{
// Act - First turn
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
Assert.NotNull(response1);
// Act - Second turn using same session
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
// Act - Second turn using same session
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
// Assert
Assert.NotNull(response2);
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
// Assert
Assert.NotNull(response2);
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithSessionResume_ContinuesConversationAsync()
{
// Arrange - First agent instance starts a conversation
string? sessionId;
SkipIfCopilotNotConfigured();
string? sessionId = null;
await using CopilotClient client1 = new(new CopilotClientOptions());
await client1.StartAsync();
@@ -125,31 +181,44 @@ public class GitHubCopilotAgentTests
instructions: "You are a helpful assistant. Keep your answers short.");
AgentSession session1 = await agent1.CreateSessionAsync();
await agent1.RunAsync("Remember this number: 42.", session1);
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
Assert.NotNull(sessionId);
try
{
await agent1.RunAsync("Remember this number: 42.", session1);
// Act - Second agent instance resumes the session
await using CopilotClient client2 = new(new CopilotClientOptions());
await client2.StartAsync();
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
Assert.NotNull(sessionId);
await using GitHubCopilotAgent agent2 = new(
client2,
instructions: "You are a helpful assistant. Keep your answers short.");
// Act - Second agent instance resumes the session
await using CopilotClient client2 = new(new CopilotClientOptions());
await client2.StartAsync();
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
await using GitHubCopilotAgent agent2 = new(
client2,
instructions: "You are a helpful assistant. Keep your answers short.");
// Assert
Assert.NotNull(response);
Assert.Contains("42", response.Text);
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
// Assert
Assert.NotNull(response);
Assert.Contains("42", response.Text);
}
finally
{
if (sessionId is not null)
{
await client1.DeleteSessionAsync(sessionId);
}
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
@@ -159,20 +228,30 @@ public class GitHubCopilotAgentTests
};
await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();
// Act
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'");
try
{
// Act
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'", session);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithUrlPermissions_FetchesContentAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
@@ -182,20 +261,30 @@ public class GitHubCopilotAgentTests
};
await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();
// Act
AgentResponse response = await agent.RunAsync(
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence");
try
{
// Act
AgentResponse response = await agent.RunAsync(
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence", session);
// Assert
Assert.NotNull(response);
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
// Assert
Assert.NotNull(response);
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
@@ -214,20 +303,31 @@ public class GitHubCopilotAgentTests
};
await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();
// Act
AgentResponse response = await agent.RunAsync("List the files in the current directory");
try
{
// Act
AgentResponse response = await agent.RunAsync("List the files in the current directory", session);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.NotEmpty(response.Text);
// Assert
Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
Assert.NotEmpty(response.Text);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
[Fact(Skip = SkipReason)]
[Fact]
[Trait("Category", "IntegrationDisabled")]
public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync()
{
// Arrange
SkipIfCopilotNotConfigured();
await using CopilotClient client = new(new CopilotClientOptions());
await client.StartAsync();
@@ -245,12 +345,28 @@ public class GitHubCopilotAgentTests
};
await using GitHubCopilotAgent agent = new(client, sessionConfig);
AgentSession session = await agent.CreateSessionAsync();
// Act
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result");
try
{
// Act
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result", session);
// Assert
Assert.NotNull(response);
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
// Assert
Assert.NotNull(response);
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(client, session);
}
}
private static async Task DeleteSessionAsync(CopilotClient client, AgentSession session)
{
if (session is GitHubCopilotAgentSession { SessionId: { } sessionId })
{
await client.DeleteSessionAsync(sessionId);
}
}
}
@@ -21,9 +21,12 @@ public class HarnessAgentTests
/// <summary>
/// Creates a HarnessAgent with all default features disabled to isolate tests for specific behaviors.
/// Compaction is enabled by default for backward compatibility with existing tests.
/// </summary>
private static HarnessAgentOptions CreateAllDisabledOptions() => new()
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
@@ -43,7 +46,7 @@ public class HarnessAgentTests
public void Constructor_ThrowsWhenChatClientIsNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!, TestMaxContextWindowTokens, TestMaxOutputTokens));
Assert.Throws<ArgumentNullException>(() => new HarnessAgent(null!));
}
/// <summary>
@@ -54,9 +57,10 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions { MaxContextWindowTokens = 0, MaxOutputTokens = TestMaxOutputTokens };
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 0, TestMaxOutputTokens));
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
}
/// <summary>
@@ -67,9 +71,10 @@ public class HarnessAgentTests
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions { MaxContextWindowTokens = 100_000, MaxOutputTokens = 100_000 };
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, 100_000, 100_000));
Assert.Throws<ArgumentOutOfRangeException>(() => new HarnessAgent(chatClient, options));
}
/// <summary>
@@ -82,7 +87,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(chatClient);
// Assert
Assert.NotNull(agent);
@@ -105,7 +110,7 @@ public class HarnessAgentTests
options.Description = "A test agent";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.Equal("TestAgent", agent.Name);
@@ -124,7 +129,7 @@ public class HarnessAgentTests
options.Id = "my-agent-id";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.Equal("my-agent-id", agent.Id);
@@ -144,7 +149,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -164,7 +169,7 @@ public class HarnessAgentTests
options.ChatOptions = new ChatOptions { Temperature = 0.5f };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -184,7 +189,7 @@ public class HarnessAgentTests
options.ChatOptions = new ChatOptions { Instructions = "You are a custom assistant." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -205,7 +210,7 @@ public class HarnessAgentTests
options.HarnessInstructions = "Custom harness rules.";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -226,7 +231,7 @@ public class HarnessAgentTests
options.ChatOptions = new ChatOptions { Instructions = "You are a research agent." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -247,7 +252,7 @@ public class HarnessAgentTests
options.ChatOptions = new ChatOptions { Instructions = "Agent only instructions." };
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -267,7 +272,7 @@ public class HarnessAgentTests
options.HarnessInstructions = string.Empty;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -289,7 +294,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -310,7 +315,7 @@ public class HarnessAgentTests
options.ChatHistoryProvider = customProvider;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -332,7 +337,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -353,7 +358,7 @@ public class HarnessAgentTests
var rawClient = mockClient.Object;
// Act
var agent = new HarnessAgent(rawClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(rawClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the pipeline wraps the raw client, so the outer client is not the same object.
@@ -378,7 +383,7 @@ public class HarnessAgentTests
options.AIContextProviders = [customProvider];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — the custom provider should appear in the inner agent's AIContextProviders.
@@ -398,7 +403,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -432,7 +437,7 @@ public class HarnessAgentTests
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [tool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -459,8 +464,10 @@ public class HarnessAgentTests
};
// Act
_ = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, new HarnessAgentOptions
_ = new HarnessAgent(chatClient, new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
ChatOptions = sourceChatOptions,
});
@@ -483,7 +490,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Same(agent, agent.GetService<HarnessAgent>());
@@ -499,7 +506,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.NotNull(agent.GetService<ChatClientAgent>());
@@ -524,7 +531,7 @@ public class HarnessAgentTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello!")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
@@ -565,7 +572,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = chatClient.AsHarnessAgent();
// Assert
Assert.NotNull(agent);
@@ -586,7 +593,7 @@ public class HarnessAgentTests
options.ChatOptions = new ChatOptions { Instructions = "Custom instructions" };
// Act
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = chatClient.AsHarnessAgent(options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -603,7 +610,7 @@ public class HarnessAgentTests
public void AsHarnessAgent_ThrowsWhenChatClientIsNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens));
Assert.Throws<ArgumentNullException>(() => ((IChatClient)null!).AsHarnessAgent());
}
#endregion
@@ -622,7 +629,7 @@ public class HarnessAgentTests
options.DisableToolApproval = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<ToolApprovalAgent>());
@@ -638,7 +645,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<ToolApprovalAgent>());
@@ -678,7 +685,7 @@ public class HarnessAgentTests
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -721,7 +728,7 @@ public class HarnessAgentTests
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -763,7 +770,7 @@ public class HarnessAgentTests
options.DisableNonApprovalRequiredFunctionBypassing = true;
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -796,7 +803,7 @@ public class HarnessAgentTests
options.DisableOpenTelemetry = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
@@ -812,7 +819,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert
Assert.Null(agent.GetService<OpenTelemetryAgent>());
@@ -831,7 +838,7 @@ public class HarnessAgentTests
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
@@ -858,7 +865,7 @@ public class HarnessAgentTests
var options = CreateAllDisabledOptions();
options.DisableWebSearch = false;
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -883,7 +890,7 @@ public class HarnessAgentTests
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(mockClient.Object, CreateAllDisabledOptions());
var session = await agent.CreateSessionAsync();
// Act
@@ -916,7 +923,7 @@ public class HarnessAgentTests
options.DisableWebSearch = false;
options.ChatOptions = new ChatOptions { Tools = [userTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(mockClient.Object, options);
var session = await agent.CreateSessionAsync();
// Act
@@ -944,7 +951,7 @@ public class HarnessAgentTests
options.DisableTodoProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -962,7 +969,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -989,7 +996,7 @@ public class HarnessAgentTests
options.DisableAgentModeProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1007,7 +1014,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1038,7 +1045,7 @@ public class HarnessAgentTests
};
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentModeProvider should be present (we can't easily inspect its internal options,
@@ -1063,7 +1070,7 @@ public class HarnessAgentTests
options.DisableFileMemory = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1081,7 +1088,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1106,7 +1113,7 @@ public class HarnessAgentTests
options.FileMemoryStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileMemoryProvider should be present with the custom store.
@@ -1130,7 +1137,7 @@ public class HarnessAgentTests
options.DisableFileAccess = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1148,7 +1155,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1173,7 +1180,7 @@ public class HarnessAgentTests
options.FileAccessStore = customStore;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — FileAccessProvider should be present with the custom store.
@@ -1197,7 +1204,7 @@ public class HarnessAgentTests
options.DisableAgentSkillsProvider = false;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1215,7 +1222,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1240,7 +1247,7 @@ public class HarnessAgentTests
options.AgentSkillsSource = customSource;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — AgentSkillsProvider should be present.
@@ -1264,7 +1271,7 @@ public class HarnessAgentTests
options.MaximumIterationsPerRequest = 42;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
@@ -1283,7 +1290,7 @@ public class HarnessAgentTests
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions());
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
var innerAgent = agent.GetService<ChatClientAgent>();
var ficc = innerAgent!.ChatClient.GetService<FunctionInvokingChatClient>();
@@ -1311,7 +1318,7 @@ public class HarnessAgentTests
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")));
// Act
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens);
var agent = new HarnessAgent(mockClient.Object);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — agent wrappers
@@ -1354,7 +1361,7 @@ public class HarnessAgentTests
options.BackgroundAgents = [bgAgentMock.Object];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1374,7 +1381,7 @@ public class HarnessAgentTests
options.BackgroundAgents = null;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1397,7 +1404,7 @@ public class HarnessAgentTests
options.BackgroundAgents = Array.Empty<AIAgent>();
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1428,7 +1435,7 @@ public class HarnessAgentTests
options.BackgroundAgentsProviderOptions = providerOptions;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
@@ -1465,7 +1472,7 @@ public class HarnessAgentTests
options.BackgroundAgents = [agent1Mock.Object, agent2Mock.Object];
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
var bgProvider = innerAgent!.AIContextProviders!.OfType<BackgroundAgentsProvider>().Single();
@@ -1506,7 +1513,7 @@ public class HarnessAgentTests
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1526,7 +1533,7 @@ public class HarnessAgentTests
options.ShellExecutor = null;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert
@@ -1558,7 +1565,7 @@ public class HarnessAgentTests
options.ShellExecutor = executorMock.Object;
// Act
var agent = new HarnessAgent(chatClientMock.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClientMock.Object, options);
var session = await agent.CreateSessionAsync();
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
@@ -1587,7 +1594,7 @@ public class HarnessAgentTests
options.ShellEnvironmentProviderOptions = envOptions;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var agent = new HarnessAgent(chatClient, options);
var innerAgent = agent.GetService<ChatClientAgent>();
// Assert — provider should exist (options wiring is validated by the provider's behavior)
@@ -1611,7 +1618,7 @@ public class HarnessAgentTests
var loggerFactory = new Mock<ILoggerFactory>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory);
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory);
// Assert
Assert.NotNull(agent);
@@ -1628,7 +1635,7 @@ public class HarnessAgentTests
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services);
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: services);
// Assert
Assert.NotNull(agent);
@@ -1646,7 +1653,7 @@ public class HarnessAgentTests
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), loggerFactory, services);
// Assert
Assert.NotNull(agent);
@@ -1664,7 +1671,7 @@ public class HarnessAgentTests
var services = new Mock<IServiceProvider>().Object;
// Act
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
var agent = chatClient.AsHarnessAgent(CreateAllDisabledOptions(), loggerFactory, services);
// Assert
Assert.NotNull(agent);
@@ -1686,6 +1693,8 @@ public class HarnessAgentTests
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
var options = new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
MaxOutputTokens = TestMaxOutputTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
@@ -1694,7 +1703,7 @@ public class HarnessAgentTests
DisableTodoProvider = true,
DisableAgentModeProvider = true,
};
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object);
var agent = new HarnessAgent(chatClient, options, mockLoggerFactory.Object);
// Assert — CreateLogger should have been called by one or more downstream components
Assert.NotNull(agent);
@@ -1716,7 +1725,7 @@ public class HarnessAgentTests
.Returns(null!);
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object);
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions(), services: mockServices.Object);
// Assert — the service provider should have been queried during pipeline construction
Assert.NotNull(agent);
@@ -1724,4 +1733,91 @@ public class HarnessAgentTests
}
#endregion
#region Compaction Opt-in
/// <summary>
/// Verify that constructing without token values succeeds (compaction disabled).
/// </summary>
[Fact]
public void Constructor_SucceedsWithoutTokenValues()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions
{
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert — compaction should be disabled (no chat reducer)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.Null(historyProvider!.ChatReducer);
}
/// <summary>
/// Verify that when only MaxContextWindowTokens is provided (no MaxOutputTokens), compaction is disabled.
/// </summary>
[Fact]
public void Constructor_SucceedsWithOnlyMaxContextWindowTokens()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = new HarnessAgentOptions
{
MaxContextWindowTokens = TestMaxContextWindowTokens,
DisableToolApproval = true,
DisableOpenTelemetry = true,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableAgentSkillsProvider = true,
};
// Act
var agent = new HarnessAgent(chatClient, options);
// Assert — compaction should be disabled (only one token value provided)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.Null(historyProvider!.ChatReducer);
}
/// <summary>
/// Verify that when both token values are provided, the agent is constructed successfully with compaction.
/// </summary>
[Fact]
public void Constructor_SucceedsWithBothTokenValues()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
// Act
var agent = new HarnessAgent(chatClient, CreateAllDisabledOptions());
// Assert — compaction should be enabled (chat reducer configured)
var innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
var historyProvider = innerAgent!.ChatHistoryProvider as InMemoryChatHistoryProvider;
Assert.NotNull(historyProvider);
Assert.NotNull(historyProvider!.ChatReducer);
}
#endregion
}
@@ -115,6 +115,24 @@ public sealed class PurviewClientTests : IDisposable
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
}
[Fact]
public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync()
{
// Arrange
var request = CreateValidProcessContentRequest();
request.ProcessInline = true;
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
// Act
await this._client.ProcessContentAsync(request, CancellationToken.None);
// Assert
Assert.Equal("evaluateInline", this._handler.PreferHeader);
}
[Fact]
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
{
@@ -530,6 +548,7 @@ public sealed class PurviewClientTests : IDisposable
public HttpMethod? RequestMethod { get; private set; }
public string? AuthorizationHeader { get; private set; }
public string? IfNoneMatchHeader { get; private set; }
public string? PreferHeader { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
@@ -547,6 +566,11 @@ public sealed class PurviewClientTests : IDisposable
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
}
if (request.Headers.TryGetValues("Prefer", out var preferValues))
{
this.PreferHeader = string.Join(", ", preferValues);
}
// Throw HttpRequestException if configured
if (this.ShouldThrowHttpRequestException)
{
@@ -3,12 +3,14 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Agents.AI.Purview.Models.Jobs;
using Microsoft.Agents.AI.Purview.Models.Requests;
using Microsoft.Agents.AI.Purview.Models.Responses;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Purview.UnitTests;
@@ -50,10 +52,6 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes =
@@ -70,8 +68,8 @@ public sealed class ScopedContentProcessorTests
]
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
@@ -109,10 +107,6 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes =
@@ -129,8 +123,8 @@ public sealed class ScopedContentProcessorTests
]
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
@@ -168,10 +162,6 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes =
@@ -188,8 +178,8 @@ public sealed class ScopedContentProcessorTests
]
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
@@ -213,6 +203,99 @@ public sealed class ScopedContentProcessorTests
Assert.Equal("user-123", result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Test message")
];
PurviewSettings settings = CreateValidPurviewSettings();
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block };
ProcessContentResponse pcResponse = new()
{
PolicyActions =
[
processContentAction
]
};
ProtectionScopesResponse psResponse = new()
{
Scopes =
[
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations =
[
new("microsoft.graph.policyLocationApplication", "app-123")
],
ExecutionMode = ExecutionMode.EvaluateInline,
PolicyActions =
[
duplicateScopeAction,
restrictionOnlyAction
]
}
]
};
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
Assert.NotNull(pcResponse.PolicyActions);
Assert.Equal(2, pcResponse.PolicyActions.Count);
Assert.Same(processContentAction, pcResponse.PolicyActions[0]);
Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]);
}
[Fact]
public void CheckApplicableScopes_MatchesAnyLocationInScope()
{
// Arrange
ProcessContentRequest pcRequest = CreateProcessContentRequest();
ProtectionScopesResponse psResponse = new()
{
Scopes =
[
new()
{
Activities = ProtectionScopeActivities.UploadText,
Locations =
[
new("microsoft.graph.policyLocationApplication", "app-123"),
new("microsoft.graph.policyLocationApplication", "different-app")
],
ExecutionMode = ExecutionMode.EvaluateInline
}
]
};
// Act
(bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse);
// Assert
Assert.True(shouldProcess);
Assert.Equal(ExecutionMode.EvaluateInline, executionMode);
}
[Fact]
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
{
@@ -279,12 +362,9 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
ScopeIdentifier = "etag-1",
Scopes =
[
new()
@@ -299,8 +379,8 @@ public sealed class ScopedContentProcessorTests
]
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
var pcResponse = new ProcessContentResponse
@@ -336,10 +416,6 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse
{
Scopes =
@@ -355,8 +431,8 @@ public sealed class ScopedContentProcessorTests
]
};
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
@@ -432,13 +508,9 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
var psResponse = new ProtectionScopesResponse { Scopes = [] };
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = [] };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
@@ -467,13 +539,9 @@ public sealed class ScopedContentProcessorTests
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
var psResponse = new ProtectionScopesResponse { Scopes = [] };
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var psResponse = new ProtectionScopesResponse { Scopes = [] };
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(psResponse);
// Act
@@ -484,10 +552,260 @@ public sealed class ScopedContentProcessorTests
Assert.Equal(userId, result.userId);
}
[Fact]
public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessContentResponse());
// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
}
[Fact]
public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
var pcResponse = new ProcessContentResponse
{
PolicyActions =
[
new() { Action = DlpAction.BlockAccess }
]
};
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(pcResponse);
// Act
var result = await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert
Assert.True(result.shouldBlock);
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
}
[Fact]
public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ProtectionScopesResponse?)null);
this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()))
.Throws(new PurviewJobException("queue unavailable"));
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessContentResponse());
// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
// Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync()
{
// Arrange
var messages = new List<ChatMessage>
{
new (ChatRole.User, "Test message")
};
var settings = CreateValidPurviewSettings();
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
.ReturnsAsync(tokenInfo);
this._mockCacheProvider.Setup(x => x.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
It.IsAny<PaymentRequiredCacheKey>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentRequiredCacheEntry("Payment required"));
// Act + Assert
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Never);
}
[Fact]
public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
{
// Arrange
Func<Channel<BackgroundJobBase>, Task>? runner = null;
Mock<IChannelHandler> channelHandler = new();
Mock<IPurviewClient> purviewClient = new();
Mock<ICacheProvider> cacheProvider = new();
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
ProtectionScopesRequest request = new("user-123", "tenant-123")
{
Activities = ProtectionScopeActivities.UploadText,
Locations =
[
new("microsoft.graph.policyLocationApplication", "app-123")
]
};
ProtectionScopesCacheKey cacheKey = new(request);
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
// Act
Assert.NotNull(runner);
await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
channel.Writer.Complete();
await runner(channel);
// Assert
cacheProvider.Verify(x => x.SetAsync(
It.Is<PaymentRequiredCacheKey>(key => key.TenantId == "tenant-123"),
It.Is<PaymentRequiredCacheEntry>(entry => entry.Message == "Payment required"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
{
// Arrange
Func<Channel<BackgroundJobBase>, Task>? runner = null;
Mock<IChannelHandler> channelHandler = new();
Mock<IPurviewClient> purviewClient = new();
Mock<ICacheProvider> cacheProvider = new();
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
ProtectionScopesRequest request = CreateProtectionScopesRequest();
ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest());
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
// Act
Assert.NotNull(runner);
await channel.Writer.WriteAsync(job);
channel.Writer.Complete();
await runner(channel);
// Assert
channelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
}
#endregion
#region Helper Methods
private static ProtectionScopesRequest CreateProtectionScopesRequest()
{
return new ProtectionScopesRequest("user-123", "tenant-123")
{
Activities = ProtectionScopeActivities.UploadText,
Locations =
[
new("microsoft.graph.policyLocationApplication", "app-123")
]
};
}
private static ProcessContentRequest CreateProcessContentRequest()
{
PurviewTextContent content = new("Test content");
ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id");
ActivityMetadata activityMetadata = new(Activity.UploadText);
DeviceMetadata deviceMetadata = new()
{
OperatingSystemSpecifications = new()
{
OperatingSystemPlatform = "Windows",
OperatingSystemVersion = "10"
}
};
IntegratedAppMetadata integratedAppMetadata = new()
{
Name = "TestApp",
Version = "1.0"
};
PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123");
ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
{
Name = "TestApp",
Version = "1.0"
};
ContentToProcess contentToProcess = new(
[metadata],
activityMetadata,
deviceMetadata,
integratedAppMetadata,
protectedAppMetadata);
return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123");
}
private static PurviewSettings CreateValidPurviewSettings()
{
return new PurviewSettings("TestApp")
@@ -347,6 +347,115 @@ public class ChatClientAgent_ChatOptionsMergingTests
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request takes priority over the agent's.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestReasoningOverAgentReasoningAsync()
{
// Arrange
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> falls back to the agent's when the request has none.
/// </summary>
[Fact]
public async Task ChatOptionsMergingFallsBackToAgentReasoningWhenRequestHasNoneAsync()
{
// Arrange
var agentReasoning = new ReasoningOptions { Effort = ReasoningEffort.Low, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions { Reasoning = agentReasoning }
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions()));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(agentReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(agentReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that <see cref="ChatOptions.Reasoning"/> from the request is used when the agent has none.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestReasoningWhenAgentHasNoneAsync()
{
// Arrange
var requestReasoning = new ReasoningOptions { Effort = ReasoningEffort.High, Output = ReasoningOutput.Full };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new ChatOptions()
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(new ChatOptions { Reasoning = requestReasoning }));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Reasoning);
Assert.Equal(requestReasoning.Effort, capturedChatOptions.Reasoning.Effort);
Assert.Equal(requestReasoning.Output, capturedChatOptions.Reasoning.Output);
}
/// <summary>
/// Verify that ChatOptions merging handles all scalar properties correctly.
/// </summary>
@@ -321,6 +321,189 @@ public sealed class DefaultMcpToolHandlerTests
#endregion
#region ComputeHeadersHash Tests
[Fact]
public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString()
{
// Act
string result = DefaultMcpToolHandler.ComputeHeadersHash(null);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString()
{
// Act
string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary<string, string>());
// Assert
result.Should().BeEmpty();
}
[Fact]
public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash()
{
// Arrange
Dictionary<string, string> headers1 = new()
{
["Authorization"] = "Bearer token123",
["X-Custom"] = "value1"
};
Dictionary<string, string> headers2 = new()
{
["X-Custom"] = "value1",
["Authorization"] = "Bearer token123"
};
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash()
{
// Arrange — RFC 7230: header names are case-insensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token" };
Dictionary<string, string> headers2 = new() { ["authorization"] = "Bearer token" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash()
{
// Arrange — RFC 7235: credentials are case-sensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash()
{
// Arrange
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token1" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer token2" };
// Act
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
// Assert
hash1.Should().NotBe(hash2);
}
#endregion
#region Cache Key Discrimination Tests
// These tests exercise BuildCacheKey directly because the integration path
// (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior
// without standing up a real MCP server — McpClient.CreateAsync fails before
// _clients[key] = newClient runs, so nothing ever gets cached.
// Tuple equality on the returned 4-tuple verifies that the dimensions
// collectively discriminate cache entries.
[Fact]
public void BuildCacheKey_SameInputs_ReturnsEqualKeys()
{
// Arrange
Dictionary<string, string> headers = new() { ["Authorization"] = "Bearer token" };
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
// Assert
key1.Should().Be(key2);
}
[Fact]
public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
{
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null);
// Assert
key1.Should().NotBe(key2);
key1.Connection.Should().Be("connection-a");
key2.Connection.Should().Be("connection-b");
}
[Fact]
public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys()
{
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null);
// Assert
key1.Should().NotBe(key2);
key1.Label.Should().Be("label-a");
key2.Label.Should().Be("label-b");
}
[Fact]
public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys()
{
// Arrange — RFC 3986: URL path is case-sensitive
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null);
// Assert
key1.Should().NotBe(key2);
}
[Fact]
public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
{
// Arrange — RFC 7235: credentials are case-sensitive
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
// Act
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1);
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2);
// Assert — header value case must propagate into the cache key
key1.Should().NotBe(key2);
key1.HeadersHash.Should().NotBe(key2.HeadersHash);
}
[Fact]
public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString()
{
// Act
var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null);
// Assert — verifies null-safety contract callers rely on
key.Label.Should().BeEmpty();
key.Connection.Should().BeEmpty();
key.HeadersHash.Should().BeEmpty();
}
#endregion
#region Reserved Tools/List Tests
[Fact]
@@ -1,11 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
@@ -261,6 +271,323 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
#endregion
#region Approval Snapshot Security Tests
/// <summary>
/// Verifies that mutating the function-name variable after approval does not change
/// which function is actually invoked. The originally-approved name must be used.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync()
{
// Arrange
const string ApprovedFunctionName = "safe_readonly_query";
const string MutatedFunctionName = "dangerous_admin_tool";
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync),
variableName: "TargetFunction");
string? capturedFunctionName = null;
TestFunctionAgentProvider testAgentProvider = new(
[
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
],
onInvoke: name => capturedFunctionName = name);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate parallel branch mutating state during the approval window
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
this.State.Bind();
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved function must be invoked, not the mutated one
Assert.NotNull(capturedFunctionName);
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
}
/// <summary>
/// Verifies that mutating an argument variable after approval does not change
/// the arguments actually passed to the invoked function.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
{
// Arrange
const string FunctionName = "process_query";
const string ArgumentKey = "query";
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
const string MutatedQuery = "DROP TABLE users CASCADE; --";
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableArgument(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
functionName: FunctionName,
argumentKey: ArgumentKey,
variableName: "SqlQuery");
AIFunctionArguments? capturedArguments = null;
TestFunctionAgentProvider testAgentProvider = new(
[AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)],
onInvokeArguments: args => capturedArguments = args);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate parallel branch mutating state during the approval window
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
this.State.Bind();
// User clicks approve
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved argument must be used, not the mutated one
Assert.NotNull(capturedArguments);
Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString());
}
/// <summary>
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
/// After restore, the originally-approved function must still be used even if state was mutated.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
{
// Arrange
const string ApprovedFunctionName = "safe_readonly_query";
const string MutatedFunctionName = "dangerous_admin_tool";
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
variableName: "TargetFunction");
string? capturedFunctionName = null;
TestFunctionAgentProvider testAgentProvider = new(
[
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
],
onInvoke: name => capturedFunctionName = name);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - trigger ExecuteAsync to store the approval snapshot
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Simulate checkpoint: persist to state store
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
// (In production, a new executor instance would be created with _approvalSnapshot == null)
typeof(InvokeFunctionToolExecutor)
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(action, null);
// Restore from state store
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
// Mutate state after restore (simulating parallel branch)
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
this.State.Bind();
// User clicks approve
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
// Resume after approval
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - the originally-approved function must be invoked, not the mutated one
Assert.NotNull(capturedFunctionName);
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
}
/// <summary>
/// Verifies that the approval snapshot is cleared after a completed approval cycle,
/// both in-memory and in the persisted state store. This prevents stale data from
/// influencing a subsequent execution of the same executor instance.
/// </summary>
[Fact]
public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync()
{
// Arrange
const string FunctionName = "any_function";
this.State.InitializeSystem();
this.State.Bind();
InvokeFunctionTool model = this.CreateModel(
displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync),
functionName: FunctionName,
requireApproval: true);
TestFunctionAgentProvider testAgentProvider = new(
[AIFunctionFactory.Create(() => "result", name: FunctionName)]);
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
// Act - run the full approval cycle
Dictionary<string, object?> stateStore = [];
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore(stateStore);
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
// Sanity: snapshot was captured
FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor)
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!;
Assert.NotNull(snapshotField.GetValue(action));
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
// Assert - both in-memory field and persisted state are cleared
Assert.Null(snapshotField.GetValue(action));
Assert.True(stateStore.ContainsKey("_approvalSnapshot"));
Assert.Null(stateStore["_approvalSnapshot"]);
}
private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved)
{
FunctionCallContent functionCall = new(callId: actionId, name: "ignored");
ToolApprovalRequestContent approvalRequest = new(actionId, functionCall);
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse]));
}
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
{
Mock<IWorkflowContext> mockContext = new();
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
return mockContext;
}
/// <summary>
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
/// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state.
/// </summary>
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore(Dictionary<string, object?>? stateStore = null)
{
stateStore ??= [];
Mock<IWorkflowContext> mockContext = new();
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
.Returns(default(ValueTask));
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns(default(ValueTask));
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns<string, string?, CancellationToken>((key, _, _) =>
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HashSet<string>());
return mockContext;
}
/// <summary>
/// Invokes a protected method on the executor via reflection (for testing checkpoint hooks).
/// </summary>
private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
{
MethodInfo method = typeof(InvokeFunctionToolExecutor)
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
await result.ConfigureAwait(false);
}
/// <summary>
/// Minimal concrete <see cref="ResponseAgentProvider"/> that exposes an injected
/// <see cref="AIFunction"/> registry and records which function got invoked.
/// Used by the framework-invoke approval branch (<c>InvokeRegisteredFunctionAsync</c>).
/// </summary>
private sealed class TestFunctionAgentProvider : ResponseAgentProvider
{
private readonly Action<string>? _onInvoke;
private readonly Action<AIFunctionArguments>? _onInvokeArguments;
public TestFunctionAgentProvider(
IEnumerable<AIFunction> functions,
Action<string>? onInvoke = null,
Action<AIFunctionArguments>? onInvokeArguments = null)
{
this._onInvoke = onInvoke;
this._onInvokeArguments = onInvokeArguments;
this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList();
}
internal void RecordInvocation(string name, AIFunctionArguments? arguments)
{
this._onInvoke?.Invoke(name);
if (arguments is not null)
{
this._onInvokeArguments?.Invoke(arguments);
}
}
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
string agentId, string? agentVersion, string? conversationId,
IEnumerable<ChatMessage>? messages, IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId, int? limit = null, string? after = null, string? before = null,
bool newestFirst = false, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction
{
public override string Name => inner.Name;
public override string Description => inner.Description;
public override JsonElement JsonSchema => inner.JsonSchema;
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
{
owner.RecordInvocation(inner.Name, arguments);
return inner.InvokeAsync(arguments, cancellationToken);
}
}
}
#endregion
#region Helper Methods
private async Task ExecuteTestAsync(InvokeFunctionTool model)
@@ -318,5 +645,33 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
return AssignParent<InvokeFunctionTool>(builder);
}
private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName)
{
InvokeFunctionTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
FunctionName = new StringExpression.Builder(
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
};
return AssignParent<InvokeFunctionTool>(builder);
}
private InvokeFunctionTool CreateModelWithVariableArgument(
string displayName, string functionName, string argumentKey, string variableName)
{
InvokeFunctionTool.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)),
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
};
builder.Arguments.Add(argumentKey,
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
return AssignParent<InvokeFunctionTool>(builder);
}
#endregion
}
@@ -419,6 +419,82 @@ public class MagenticOrchestrationTests
"final-answer synthesis must see what participants actually said");
}
[Fact]
public async Task Participant_Receives_Prior_Participant_Response_Not_InstructionAsync()
{
// Regression: each participant must see prior participants' *responses* (the running conversation),
// not their *instructions*. Previously the orchestrator broadcast the per-round instruction to every
// participant (untargeted fan-out) and never broadcast replies, so a later speaker received the earlier
// speaker's instruction and never its answer.
const string HealthInstruction = "HEALTH_CHECKER_INSTRUCTION_check_framework";
const string DatabaseInstruction = "DATABASE_CHECKER_INSTRUCTION_check_database";
const string HealthEchoPrefix = "HC_RESPONSE::";
const string DatabaseEchoPrefix = "DB_RESPONSE::";
List<ChatMessage> facts = CreatePlanResponse("Facts");
List<ChatMessage> plan = CreatePlanResponse("Plan");
List<ChatMessage> round1Ledger = CreateProgressLedgerResponse(
isRequestSatisfied: false,
isInLoop: false,
isProgressBeingMade: true,
nextSpeaker: "HealthChecker",
instructionOrQuestion: HealthInstruction);
List<ChatMessage> round2Ledger = CreateProgressLedgerResponse(
isRequestSatisfied: false,
isInLoop: false,
isProgressBeingMade: true,
nextSpeaker: "DatabaseChecker",
instructionOrQuestion: DatabaseInstruction);
List<ChatMessage> round3Ledger = CreateProgressLedgerResponse(
isRequestSatisfied: true,
isInLoop: false,
isProgressBeingMade: true,
nextSpeaker: "DatabaseChecker",
instructionOrQuestion: "Done");
List<ChatMessage> finalAnswer = CreateFinalAnswerResponse("All systems checked");
TestReplayAgent manager = new(
[facts, plan, round1Ledger, round2Ledger, round3Ledger, finalAnswer],
name: "Manager");
RecordingEchoAgent healthChecker = new(name: "HealthChecker", prefix: HealthEchoPrefix);
RecordingEchoAgent databaseChecker = new(name: "DatabaseChecker", prefix: DatabaseEchoPrefix);
Workflow workflow = new MagenticWorkflowBuilder(manager)
.AddParticipants(healthChecker, databaseChecker)
.RequirePlanSignoff(false)
.Build();
WorkflowRunResult runResult = await RunMagenticWorkflowAsync(
workflow,
[new ChatMessage(ChatRole.User, "Check system health")]);
runResult.Result.Should().NotBeNull();
runResult.Result![0].Text.Should().Contain("All systems checked");
// Each participant takes exactly one turn.
healthChecker.RecordedInputs.Should().ContainSingle();
databaseChecker.RecordedInputs.Should().ContainSingle();
// The first speaker receives its own instruction.
List<ChatMessage> healthInput = healthChecker.RecordedInputs[0];
healthInput.Should().Contain(m => m.Text.Contains(HealthInstruction), "the first speaker receives its own instruction");
// The second speaker must see the first speaker's RESPONSE (authored by HealthChecker, carrying the echo
// prefix that only the response — not the raw instruction — has), plus its own instruction.
List<ChatMessage> databaseInput = databaseChecker.RecordedInputs[0];
databaseInput.Should().Contain(
m => m.AuthorName == "HealthChecker" && m.Text.Contains(HealthEchoPrefix),
"the next speaker must receive the prior participant's response (the running conversation)");
databaseInput.Should().Contain(m => m.Text.Contains(DatabaseInstruction),
"the next speaker must receive its own instruction");
// The leaked-instruction bug: the second speaker must not receive HealthChecker's instruction as a
// bare message (it should only appear, if at all, embedded in HealthChecker's prefixed response).
databaseInput.Should().NotContain(
m => m.AuthorName != "HealthChecker" && m.Text.Trim() == HealthInstruction,
"the prior speaker's instruction must not leak into the next speaker's context as a standalone message");
}
[Fact]
public async Task PlanReview_Revised_Triggers_ReplanAsync()
{
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// A <see cref="TestEchoAgent"/> that records the input messages it receives on each call.
/// Used by tests that need to assert what context a participant was actually handed - for example,
/// that a later speaker sees prior participants' <em>responses</em> (the running conversation) rather
/// than their <em>instructions</em>.
/// </summary>
internal sealed class RecordingEchoAgent(string? id = null, string? name = null, string? prefix = null)
: TestEchoAgent(id, name, prefix)
{
public List<List<ChatMessage>> RecordedInputs { get; } = [];
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Materialize once so the deferred input is recorded and replayed identically.
List<ChatMessage> recorded = messages.ToList();
this.RecordedInputs.Add(recorded);
await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(recorded, session, options, cancellationToken))
{
yield return update;
}
}
}
+22 -1
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.8.1] - 2026-06-09
### Added
- **agent-framework-core**: Add MCP client OTel spans per GenAI semantic conventions ([#6349](https://github.com/microsoft/agent-framework/pull/6349))
- **agent-framework-core**: Add MCP long-running task support ([#6319](https://github.com/microsoft/agent-framework/pull/6319))
### Changed
- **agent-framework-claude**: Bump `claude-agent-sdk` to 0.2.87 ([#6248](https://github.com/microsoft/agent-framework/pull/6248))
- **agent-framework-core**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295))
- **agent-framework-azurefunctions**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295))
### Fixed
- **agent-framework-core**: Filter MCP tool kwargs to declared params via allowlist ([#6399](https://github.com/microsoft/agent-framework/pull/6399))
- **agent-framework-core**: Fix per-service-call history persistence with server-storing clients ([#6310](https://github.com/microsoft/agent-framework/pull/6310))
- **agent-framework-openai**: Use `getattr` for non-OpenAI provider response compatibility ([#6270](https://github.com/microsoft/agent-framework/pull/6270))
- **agent-framework-foundry-hosting**: Refactor workflow-as-agent pending request handling ([#6259](https://github.com/microsoft/agent-framework/pull/6259))
- **agent-framework-gemini**: Make Gemini honor declarative `outputSchema`, not just JSON mode ([#5893](https://github.com/microsoft/agent-framework/pull/5893))
- **agent-framework-mem0**: Isolate entity retrieval and correct `app_id` payload ([#6242](https://github.com/microsoft/agent-framework/pull/6242))
- **agent-framework-ag-ui**: Match AG-UI approval responses to requested arguments ([#6376](https://github.com/microsoft/agent-framework/pull/6376))
## [1.8.0] - 2026-06-04
### Added
@@ -1169,7 +1189,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...HEAD
[1.8.1]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...python-1.8.1
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0rc3"
version = "1.0.0rc4"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"agent-framework-core>=1.8.1,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<1"
@@ -14,6 +14,24 @@ This module adds:
- reconstruct_to_type: for HITL responses where external data (without type markers)
needs to be reconstructed to a known type
- resolve_type: resolves 'module:class' type keys to Python types
Security Model
--------------
The underlying Azure Durable Functions storage (Azure Storage account) is the
trusted persistence layer for serialized checkpoint data. The
``RestrictedUnpickler`` in the core encoding module provides defense-in-depth
type filtering, but checkpoint storage itself must be properly access-controlled:
- Ensure the Azure Storage account used by Durable Functions is not publicly
writable and uses appropriate RBAC / shared-access policies.
- Never route untrusted user input directly into ``deserialize_value`` without
first calling :func:`strip_pickle_markers` to neutralize injection of
pickle markers into the data path.
- Configure your checkpoint storage with ``allowed_checkpoint_types`` (or call
``decode_checkpoint_value(..., allowed_types=...)`` directly) to restrict the set of types that can be deserialized.
See :mod:`agent_framework._workflows._checkpoint_encoding` for the full
security model documentation.
"""
from __future__ import annotations
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260604"
version = "1.0.0b260609"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.8.0,<2",
"agent-framework-core>=1.8.1,<2",
"agent-framework-durabletask>=1.0.0b260604,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -221,9 +221,31 @@ class ClaudeAgentOptions(TypedDict, total=False):
thinking: ThinkingConfig
"""Extended thinking configuration (adaptive, enabled, or disabled)."""
effort: Literal["low", "medium", "high", "max"]
effort: Literal["low", "medium", "high", "xhigh", "max"]
"""Effort level for thinking depth."""
skills: list[str] | Literal["all"]
"""Skills to enable for the main session. Use ``"all"`` for every discovered skill,
a list of named skills, or ``[]`` to suppress all skills."""
session_id: str
"""Use a specific session ID (must be a valid UUID) instead of auto-generated."""
task_budget: dict[str, int]
"""API-side task budget in tokens for pacing tool use."""
include_hook_events: bool
"""When True, hook lifecycle events are emitted in the message stream."""
strict_mcp_config: bool
"""When True, only use MCP servers passed via ``mcp_servers``, ignoring all others."""
continue_conversation: bool
"""Continue the most recent conversation instead of starting a new one."""
fork_session: bool
"""When True, resumed sessions fork to a new session ID."""
on_function_approval: FunctionApprovalCallback
"""Approval callback for ``FunctionTool`` instances declared with
``approval_mode="always_require"``. The callback is awaited (sync or async)
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260521"
version = "1.0.0b260609"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
"agent-framework-core>=1.8.1,<2",
"claude-agent-sdk>=0.1.36,<0.3",
]
[tool.uv]
+3
View File
@@ -80,6 +80,9 @@ agent_framework/
- **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s.
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument.
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins.
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
- `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel.
@@ -124,7 +124,7 @@ from ._harness._todo import (
TodoSessionStore,
TodoStore,
)
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool, SamplingApprovalCallback
from ._middleware import (
AgentContext,
AgentMiddleware,
@@ -472,6 +472,7 @@ __all__ = [
"RunContext",
"Runner",
"RunnerContext",
"SamplingApprovalCallback",
"SecretString",
"SelectiveToolCallCompactionStrategy",
"SessionContext",
@@ -66,23 +66,45 @@ def _assemble_instructions(
def _assemble_compaction_provider(
*,
disable_compaction: bool,
max_context_window_tokens: int,
max_output_tokens: int,
max_context_window_tokens: int | None,
max_output_tokens: int | None,
history_source_id: str,
before_compaction_strategy: CompactionStrategy | None,
after_compaction_strategy: CompactionStrategy | None,
tokenizer: TokenizerProtocol | None,
) -> CompactionProvider | None:
"""Build the compaction provider from parameters or defaults."""
"""Build the compaction provider from parameters or defaults.
The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and
``ToolResultCompactionStrategy`` for the after phase) are only applied when the token
params are provided. Caller-supplied strategies are always honored. Either phase may end
up ``None``, which ``CompactionProvider`` interprets as "skip that phase".
Returns None when compaction is explicitly disabled, or when neither phase has a strategy
(no custom strategies and no token budget to build the defaults).
"""
if disable_compaction:
return None
before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy(
max_context_window_tokens=max_context_window_tokens,
max_output_tokens=max_output_tokens,
tokenizer=tokenizer,
)
after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
# Resolve the before-strategy: custom strategy wins; otherwise fall back to the
# token-budget-aware default when token params are available.
before_strategy = before_compaction_strategy
if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
before_strategy = ContextWindowCompactionStrategy(
max_context_window_tokens=max_context_window_tokens,
max_output_tokens=max_output_tokens,
tokenizer=tokenizer,
)
# Resolve the after-strategy: custom strategy wins; otherwise fall back to the default
# when token params are available.
after_strategy = after_compaction_strategy
if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
# Nothing to compact in either phase: skip the provider entirely.
if before_strategy is None and after_strategy is None:
return None
return CompactionProvider(
before_strategy=before_strategy,
@@ -157,8 +179,8 @@ def create_harness_agent(
harness_instructions: str | None = None,
agent_instructions: str | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
max_context_window_tokens: int,
max_output_tokens: int,
max_context_window_tokens: int | None = None,
max_output_tokens: int | None = None,
history_provider: HistoryProvider | None = None,
disable_compaction: bool = False,
before_compaction_strategy: CompactionStrategy | None = None,
@@ -206,8 +228,6 @@ def create_harness_agent(
agent = create_harness_agent(
OpenAIChatClient(model="gpt-4o"),
max_context_window_tokens=128_000,
max_output_tokens=16_384,
)
session = agent.create_session()
response = await agent.run("Plan a weekend trip to Seattle", session=session)
@@ -243,13 +263,21 @@ def create_harness_agent(
(e.g., "You are a research assistant focused on academic sources.").
tools: Additional tools to include in the agent's toolset.
max_context_window_tokens: Maximum tokens the model's context window supports.
Used to construct the default token-budget-aware compaction strategies. When None
(default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy``
is provided, compaction is automatically disabled.
max_output_tokens: Maximum output tokens per response.
Used to construct the default compaction strategies and sets a default max_tokens
chat option. When None (default), no default max_tokens option is set, and unless a
custom compaction strategy is provided, compaction is automatically disabled.
history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used.
disable_compaction: When True, skip compaction provider setup.
before_compaction_strategy: Custom before-run compaction strategy.
Defaults to ContextWindowCompactionStrategy (token-budget aware).
after_compaction_strategy: Custom after-run compaction strategy.
Defaults to ToolResultCompactionStrategy.
before_compaction_strategy: Custom before-run compaction strategy. When provided,
compaction runs even if token params are omitted. Defaults to
ContextWindowCompactionStrategy (token-budget aware) when token params are provided.
after_compaction_strategy: Custom after-run compaction strategy. When provided,
compaction runs even if token params are omitted. Defaults to
ToolResultCompactionStrategy when token params are provided.
tokenizer: Custom tokenizer for compaction strategies.
disable_todo: When True, skip the TodoProvider.
todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True.
@@ -283,14 +311,19 @@ def create_harness_agent(
A fully configured :class:`~agent_framework.Agent` instance.
Raises:
ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0
or max_output_tokens >= max_context_window_tokens.
ValueError: If max_context_window_tokens is provided and <= 0, or
max_output_tokens is provided and <= 0, or max_output_tokens >=
max_context_window_tokens when both are provided.
"""
if max_context_window_tokens <= 0:
if max_context_window_tokens is not None and max_context_window_tokens <= 0:
raise ValueError("max_context_window_tokens must be positive.")
if max_output_tokens < 0:
raise ValueError("max_output_tokens must be non-negative.")
if max_output_tokens >= max_context_window_tokens:
if max_output_tokens is not None and max_output_tokens <= 0:
raise ValueError("max_output_tokens must be positive.")
if (
max_context_window_tokens is not None
and max_output_tokens is not None
and max_output_tokens >= max_context_window_tokens
):
raise ValueError("max_output_tokens must be less than max_context_window_tokens.")
# Build history provider.
@@ -347,7 +380,8 @@ def create_harness_agent(
# Build default options dict.
default_opts: dict[str, Any] = dict(default_options) if default_options else {}
default_opts.setdefault("max_tokens", max_output_tokens)
if max_output_tokens is not None:
default_opts.setdefault("max_tokens", max_output_tokens)
agent = Agent(
client,
+344 -52
View File
@@ -16,6 +16,7 @@ from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ig
from dataclasses import dataclass
from datetime import timedelta
from functools import partial
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from opentelemetry import propagate
@@ -70,10 +71,51 @@ class MCPSpecificApproval(TypedDict, total=False):
_MCP_REMOTE_NAME_KEY = "_mcp_remote_name"
_MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name"
# Reserved key in an ``additional_tool_argument_names`` mapping that applies its
# values to every tool on the server rather than a single named tool.
_MCP_GLOBAL_EXTRA_ARGS_KEY = "*"
# Framework kwargs that flow through the function-invocation pipeline (via
# ``FunctionInvocationContext.kwargs``) but must never be forwarded to an MCP
# server: they are internal objects that the MCP SDK cannot serialize. They are
# dropped as a safety net when a tool declares one of them in its schema, unless
# the user explicitly opts the name back in via ``additional_tool_argument_names``
# (explicit extras always win over the denylist).
# - chat_options/tools/tool_choice/session/thread: framework runtime objects.
# - conversation_id: internal tracking ID used by services like Azure AI.
# - options: metadata/store used by AG-UI for Azure AI client requirements.
# - response_format: a Pydantic model class for structured output (not serializable).
# - _meta: reserved key extracted separately as MCP request metadata.
_MCP_FRAMEWORK_DENYLIST: frozenset[str] = frozenset({
"chat_options",
"tools",
"tool_choice",
"session",
"thread",
"conversation_id",
"options",
"response_format",
"_meta",
})
_mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers")
MCP_DEFAULT_TIMEOUT = 30
MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5
# Default safety limits applied to server-initiated MCP sampling requests
# (``sampling/createMessage``). MCP servers are untrusted third parties, so the
# default ``sampling_callback`` denies requests unless an approval callback is
# supplied, and bounds the cost of any approved request.
# - ``_DEFAULT_SAMPLING_MAX_TOKENS`` clamps the server-requested ``maxTokens``.
# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per
# session connection (the counter resets on reconnect).
_DEFAULT_SAMPLING_MAX_TOKENS = 4096
_DEFAULT_SAMPLING_MAX_REQUESTS = 25
# A user-supplied gate invoked before each server-initiated sampling request is
# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams``
# and returns (or awaits to) a truthy value to approve the request or a falsy
# value to deny it. Both synchronous and asynchronous callables are supported.
SamplingApprovalCallback = Callable[["types.CreateMessageRequestParams"], "bool | Coroutine[Any, Any, bool]"]
# region: Helpers
LOG_LEVEL_MAPPING: dict[str, int] = {
@@ -135,6 +177,34 @@ def _build_prefixed_mcp_name(
return f"{normalized_prefix}_{trimmed_name}" if trimmed_name else normalized_prefix
def _normalize_additional_tool_argument_names(
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None,
) -> tuple[set[str], dict[str, set[str]]]:
"""Split user-supplied extra argument names into global and per-tool sets.
Accepts either a sequence (applied to every tool) or a mapping keyed by remote
tool name, where the reserved key ``"*"`` is treated as global. Mapping values
may be a sequence or a single string. Returns a
``(global_extras, per_tool_extras)`` tuple.
"""
if additional_tool_argument_names is None:
return set(), {}
if isinstance(additional_tool_argument_names, str):
return {additional_tool_argument_names}, {}
if isinstance(additional_tool_argument_names, Mapping):
global_extras: set[str] = set()
per_tool_extras: dict[str, set[str]] = {}
for tool_name, names in additional_tool_argument_names.items():
# Treat a bare string value as a single name rather than iterating its characters.
names_set = {names} if isinstance(names, str) else set(names)
if tool_name == _MCP_GLOBAL_EXTRA_ARGS_KEY:
global_extras.update(names_set)
else:
per_tool_extras[tool_name] = names_set
return global_extras, per_tool_extras
return set(additional_tool_argument_names), {}
def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s)."""
carrier: dict[str, str] = {}
@@ -292,8 +362,12 @@ class MCPTool:
session: ClientSession | None = None,
request_timeout: int | None = None,
client: SupportsChatGetResponse | None = None,
sampling_approval_callback: SamplingApprovalCallback | None = None,
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
additional_properties: dict[str, Any] | None = None,
task_options: MCPTaskOptions | None = None,
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
) -> None:
"""Initialize the MCP Tool base.
@@ -324,10 +398,28 @@ class MCPTool:
session: An existing MCP client session to use.
request_timeout: Timeout in seconds for MCP requests.
client: A chat client for sampling callbacks.
sampling_approval_callback: Optional gate invoked before each server-initiated
``sampling/createMessage`` request is forwarded to ``client``. It receives the
raw ``CreateMessageRequestParams`` and may be synchronous or asynchronous;
returning a truthy value approves the request and a falsy value denies it. When
``None`` (the default), every sampling request is **denied** because MCP servers
are untrusted third parties (confused-deputy risk). To restore the legacy
auto-approve behavior, pass ``lambda params: True`` as an explicit, conscious
opt-in.
sampling_max_tokens: Upper bound applied to the server-requested ``maxTokens`` for an
approved sampling request. The effective value is ``min(requested, cap)``. Set to
``None`` to disable the cap. Defaults to ``_DEFAULT_SAMPLING_MAX_TOKENS``.
sampling_max_requests: Maximum number of sampling requests allowed per session
connection; further requests are rejected. The counter resets on reconnect. Set
to ``None`` to disable the limit. Defaults to ``_DEFAULT_SAMPLING_MAX_REQUESTS``.
additional_properties: Additional properties for the tool.
task_options: Options controlling how long-running MCP tasks are driven for
tools that advertise ``execution.taskSupport == "required"``. When ``None``,
the defaults from :class:`MCPTaskOptions` are used.
additional_tool_argument_names: Extra argument names to forward to the MCP server
in addition to each tool's declared parameters. A ``Sequence[str]`` applies to
every tool; a ``Mapping[str, Sequence[str]]`` is keyed by remote tool name with
``"*"`` as a global key. See the transport subclasses for full details.
"""
self.name = name
self.description = description or ""
@@ -352,9 +444,17 @@ class MCPTool:
self.session = session
self.request_timeout = request_timeout
self.client = client
self.sampling_approval_callback = sampling_approval_callback
self.sampling_max_tokens = sampling_max_tokens
self.sampling_max_requests = sampling_max_requests
self._sampling_request_count = 0
self._functions: list[FunctionTool] = []
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
self._tool_task_support_by_name: dict[str, str] = {}
self._tool_param_names_by_name: dict[str, set[str]] = {}
self._global_extra_arg_names, self._tool_extra_arg_names = _normalize_additional_tool_argument_names(
additional_tool_argument_names
)
self.is_connected: bool = False
self._tools_loaded: bool = False
self._prompts_loaded: bool = False
@@ -477,6 +577,9 @@ class MCPTool:
case _:
result.append(Content.from_text(str(item)))
if mcp_type.structuredContent is not None:
result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str)))
if not result:
result.append(Content.from_text("null"))
return result
@@ -778,6 +881,7 @@ class MCPTool:
self._supports_prompts = True
self._supports_logging = None
self._ping_available = True
self._sampling_request_count = 0
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
self._server_capabilities = capabilities
@@ -932,6 +1036,49 @@ class MCPTool:
except Exception as exc:
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool:
"""Run the configured sampling approval gate.
Returns ``True`` only when an approval callback is configured and approves the request.
When no callback is set, the request is denied (safe default for untrusted servers).
"""
callback = self.sampling_approval_callback
if callback is None:
logger.warning(
"Denying MCP sampling request from '%s': no 'sampling_approval_callback' configured.",
self.name,
)
return False
try:
outcome = callback(params)
if isawaitable(outcome):
outcome = await outcome
except Exception as ex:
logger.warning(
"Denying MCP sampling request from '%s': approval callback raised %s.",
self.name,
ex,
exc_info=True,
)
return False
approved = bool(outcome)
if not approved:
logger.warning("MCP sampling request from '%s' was denied by the approval callback.", self.name)
return approved
def _capped_sampling_max_tokens(self, requested: int) -> int:
"""Clamp the server-requested ``maxTokens`` to ``sampling_max_tokens`` when configured."""
cap = self.sampling_max_tokens
if cap is not None and requested > cap:
logger.warning(
"Capping MCP sampling maxTokens for '%s' from %d to %d.",
self.name,
requested,
cap,
)
return cap
return requested
async def sampling_callback(
self,
context: RequestContext[ClientSession, Any],
@@ -939,20 +1086,32 @@ class MCPTool:
) -> types.CreateMessageResult | types.ErrorData:
"""Callback function for sampling.
This function is called when the MCP server needs to get a message completed.
It uses the configured chat client to generate responses.
This function is called when the MCP server sends a ``sampling/createMessage``
request. It enforces safety guardrails and, if the request is approved, uses the
configured chat client to generate a response.
Safety:
MCP servers are untrusted third parties, so forwarding server-controlled prompts
to the chat client without review is a confused-deputy risk. This callback
therefore applies, in order: a per-session rate limit
(``sampling_max_requests``), an approval gate (``sampling_approval_callback``,
which **denies by default** when not configured), and a ``maxTokens`` cap
(``sampling_max_tokens``). To allow sampling, pass a ``sampling_approval_callback``
that returns a truthy value (use ``lambda params: True`` to auto-approve as an
explicit opt-in).
Note:
This is a simple version of this function. It can be overridden to allow
more complex sampling. It gets added to the session at initialization time,
so overriding it is the best way to customize this behavior.
This is the default implementation. It can be overridden to allow more complex
sampling. It gets added to the session at initialization time, so overriding it is
the best way to customize this behavior.
Args:
context: The request context from the MCP server.
params: The message creation request parameters.
Returns:
Either a CreateMessageResult with the generated message or ErrorData if generation fails.
Either a CreateMessageResult with the generated message or ErrorData if the request
is denied, rate limited, or generation fails.
"""
from mcp import types
@@ -961,7 +1120,38 @@ class MCPTool:
code=types.INTERNAL_ERROR,
message="No chat client available. Please set a chat client.",
)
logger.debug("Sampling callback called with params: %s", params)
logger.warning(
"MCP server '%s' sent a sampling/createMessage request (%d message(s), maxTokens=%s).",
self.name,
len(params.messages),
params.maxTokens,
)
if self.sampling_max_requests is not None:
if self._sampling_request_count >= self.sampling_max_requests:
logger.warning(
"Denying MCP sampling request from '%s': per-session limit of %d reached.",
self.name,
self.sampling_max_requests,
)
return types.ErrorData(
code=types.INVALID_REQUEST,
message="Sampling rate limit exceeded for this MCP session.",
)
self._sampling_request_count += 1
if not await self._sampling_request_approved(params):
if self.sampling_approval_callback is None:
message = (
"Sampling request denied. MCP sampling is disabled by default for untrusted "
"servers; provide a 'sampling_approval_callback' that approves the request to "
"enable it."
)
else:
message = "Sampling request denied by the 'sampling_approval_callback'."
return types.ErrorData(code=types.INVALID_REQUEST, message=message)
messages: list[Message] = []
for msg in params.messages:
messages.append(self._parse_message_from_mcp(msg))
@@ -983,7 +1173,7 @@ class MCPTool:
if params.temperature is not None:
options["temperature"] = params.temperature
options["max_tokens"] = params.maxTokens
options["max_tokens"] = self._capped_sampling_max_tokens(params.maxTokens)
if params.stopSequences is not None:
options["stop"] = params.stopSequences
@@ -1229,6 +1419,7 @@ class MCPTool:
existing_names = {func.name for func in self._functions}
tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
tool_task_support_by_name: dict[str, str] = {}
tool_param_names_by_name: dict[str, set[str]] = {}
params: types.PaginatedRequestParams | None = None
while True:
@@ -1271,14 +1462,6 @@ class MCPTool:
if task_support is not None:
tool_task_support_by_name[tool.name] = task_support
normalized_name = _normalize_mcp_name(tool.name)
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
# Skip if already loaded
if local_name in existing_names:
continue
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
# Normalize inputSchema: ensure "properties" exists for object schemas.
# Some MCP servers (e.g. zero-argument tools) omit "properties",
# which causes OpenAI API to reject the schema with a 400 error.
@@ -1288,6 +1471,24 @@ class MCPTool:
if input_schema.get("type") == "object" and "properties" not in input_schema:
input_schema["properties"] = {}
# Register declared param names before the existing-tool skip below so that
# reloads (e.g. notifications/tools/list_changed) preserve the allowlist for
# tools that are already loaded, consistent with tool_call_meta_by_name and
# tool_task_support_by_name above.
schema_properties = input_schema.get("properties")
tool_param_names_by_name[tool.name] = (
set(cast(dict[str, Any], schema_properties)) if isinstance(schema_properties, dict) else set()
)
normalized_name = _normalize_mcp_name(tool.name)
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
# Skip if already loaded
if local_name in existing_names:
continue
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
async def _call_tool_with_runtime_kwargs(
ctx: FunctionInvocationContext,
*,
@@ -1320,6 +1521,7 @@ class MCPTool:
self._tool_call_meta_by_name = tool_call_meta_by_name
self._tool_task_support_by_name = tool_task_support_by_name
self._tool_param_names_by_name = tool_param_names_by_name
async def _close_on_owner(self) -> None:
# Cancel any pending reload tasks before tearing down the session.
@@ -1530,10 +1732,14 @@ class MCPTool:
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.")
def _resolved_extra_args(self, tool_name: str) -> set[str]:
"""Return the user-configured extra argument names allowed for a tool."""
return self._global_extra_arg_names | self._tool_extra_arg_names.get(tool_name, set())
def _prepare_call_kwargs(
self, tool_name: str, kwargs: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Filter framework-only kwargs and build the merged MCP request metadata."""
"""Filter kwargs down to the tool's arguments and build the merged MCP request metadata."""
raw_user_meta: object | None = kwargs.get("_meta")
user_meta: dict[str, Any] | None = None
if raw_user_meta is not None and not isinstance(raw_user_meta, dict):
@@ -1546,27 +1752,28 @@ class MCPTool:
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
user_meta[key] = value
# Filter out framework kwargs that cannot be serialized by the MCP SDK.
# These are internal objects passed through the function invocation pipeline
# that should not be forwarded to external MCP servers.
# conversation_id is an internal tracking ID used by services like Azure AI.
# options contains metadata/store used by AG-UI for Azure AI client requirements.
# response_format is a Pydantic model class used for structured output (not serializable).
# Allowlist: forward only the tool's declared parameters (from inputSchema.properties)
# plus any user-configured extra argument names. Everything else - notably the
# framework runtime kwargs injected through the function-invocation pipeline - is
# stripped so it is never forwarded to the MCP server. Tools that declare no usable
# properties forward only the user-configured extras.
#
# The extra names come exclusively from additional_tool_argument_names, which is set in
# user code at construction time; there is no per-call override, so a model-issued tool
# call cannot change which names are allowed through.
#
# The framework denylist acts as a safety net for keys a server *declares* in its
# schema that collide with internal, non-serializable framework objects (e.g. a tool
# that declares a parameter literally named "thread"): such declared-but-denylisted
# keys are dropped. Names the user explicitly opts in via additional_tool_argument_names
# always win. The reserved _meta key is handled separately above and never forwarded as
# an argument.
declared = self._tool_param_names_by_name.get(tool_name, set())
extras = self._resolved_extra_args(tool_name)
filtered_kwargs = {
k: v
for k, v in kwargs.items()
if k
not in {
"chat_options",
"tools",
"tool_choice",
"session",
"thread",
"conversation_id",
"options",
"response_format",
"_meta",
}
if k != "_meta" and (k in extras or (k in declared and k not in _MCP_FRAMEWORK_DENYLIST))
}
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
@@ -1643,9 +1850,7 @@ class MCPTool:
return parser(fallback_result)
if task_id is None:
raise ToolExecutionException(
f"MCP server did not return a task_id or fallback result for '{tool_name}'."
)
raise ToolExecutionException(f"MCP server did not return a task_id or fallback result for '{tool_name}'.")
# Track to completion: poll until terminal, then fetch payload. Never re-issue
# tools/call past this point; reconnect-and-retry only against the same task_id.
@@ -1765,9 +1970,7 @@ class MCPTool:
transient_codes: frozenset[int] = frozenset({int(httpx.codes.REQUEST_TIMEOUT)})
while True:
request = types.ClientRequest(
types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id))
)
request = types.ClientRequest(types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id)))
try:
# GetTaskResult.ttl is required-but-Optional in the SDK; coerce below.
lenient = await self._send_with_one_reconnect(
@@ -1775,9 +1978,7 @@ class MCPTool:
)
except McpError as ex:
if ex.error.code in transient_codes:
logger.debug(
"Transient %s on tasks/get for '%s'; will retry.", ex.error.code, task_id
)
logger.debug("Transient %s on tasks/get for '%s'; will retry.", ex.error.code, task_id)
await asyncio.sleep(_MCP_TASK_MIN_POLL_INTERVAL.total_seconds())
continue
# Hard server error mid-poll: task may still be running.
@@ -1906,9 +2107,7 @@ class MCPTool:
if not self._is_connection_lost(ex):
raise
if attempt < _MCP_RECONNECT_ATTEMPTS - 1:
logger.info(
"MCP connection lost during %s; reconnecting (task_id=%s).", operation, task_id
)
logger.info("MCP connection lost during %s; reconnecting (task_id=%s).", operation, task_id)
try:
await self.connect(reset=True)
except Exception as reconn_ex:
@@ -1967,9 +2166,7 @@ class MCPTool:
"""
from mcp import types
request = types.ClientRequest(
types.CancelTaskRequest(params=types.CancelTaskRequestParams(taskId=task_id))
)
request = types.ClientRequest(types.CancelTaskRequest(params=types.CancelTaskRequestParams(taskId=task_id)))
try:
await asyncio.wait_for(
self.session.send_request(request, types.CancelTaskResult), # type: ignore[union-attr]
@@ -1979,8 +2176,7 @@ class MCPTool:
raise
except asyncio.TimeoutError:
logger.warning(
"Best-effort tasks/cancel for '%s' timed out after %.1fs; "
"remote task may still be running.",
"Best-effort tasks/cancel for '%s' timed out after %.1fs; remote task may still be running.",
task_id,
_MCP_TASK_CANCEL_TIMEOUT.total_seconds(),
)
@@ -2151,8 +2347,12 @@ class MCPStdioTool(MCPTool):
env: dict[str, str] | None = None,
encoding: str | None = None,
client: SupportsChatGetResponse | None = None,
sampling_approval_callback: SamplingApprovalCallback | None = None,
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
additional_properties: dict[str, Any] | None = None,
task_options: MCPTaskOptions | None = None,
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP stdio tool.
@@ -2197,8 +2397,32 @@ class MCPStdioTool(MCPTool):
env: The environment variables to set for the command.
encoding: The encoding to use for the command output.
client: The chat client to use for sampling.
sampling_approval_callback: Optional gate run before each server-initiated
``sampling/createMessage`` request reaches ``client``. Receives the raw
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
request, a falsy return denies it. When ``None`` (the default) every sampling
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
(``min(requested, cap)``); ``None`` disables it.
sampling_max_requests: Per-session cap on the number of sampling requests; further
requests are rejected. Resets on reconnect. ``None`` disables it.
task_options: Options for tools that advertise
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
additional_tool_argument_names: Extra argument names to forward to the MCP server in
addition to each tool's declared parameters (from its ``inputSchema.properties``).
By default only declared parameters are sent; framework runtime kwargs injected
through the function-invocation pipeline are stripped. Use this to opt specific
keys back in. Accepts either a ``Sequence[str]`` applied to every tool, or a
``Mapping[str, Sequence[str]]`` keyed by remote tool name where the reserved key
``"*"`` applies to every tool. This is configured only here in user code; there is
no per-call override, so a model-issued tool call cannot change which names pass
through. To use a server that accepts ``additionalProperties: true``, list the
extra names here and then either (1) manually extend that tool's ``inputSchema``
(via the ``.functions`` list after connecting) so the model is prompted to supply
them, or (2) supply the values yourself through ``function_invocation_kwargs``. If
a name is supplied via both the model and ``function_invocation_kwargs``, the
model-supplied value wins.
kwargs: Any extra arguments to pass to the stdio client.
"""
super().__init__(
@@ -2216,6 +2440,10 @@ class MCPStdioTool(MCPTool):
parse_prompt_results=parse_prompt_results,
request_timeout=request_timeout,
task_options=task_options,
additional_tool_argument_names=additional_tool_argument_names,
sampling_approval_callback=sampling_approval_callback,
sampling_max_tokens=sampling_max_tokens,
sampling_max_requests=sampling_max_requests,
)
self.command = command
self.args = args or []
@@ -2291,10 +2519,14 @@ class MCPStreamableHTTPTool(MCPTool):
allowed_tools: Collection[str] | None = None,
terminate_on_close: bool | None = None,
client: SupportsChatGetResponse | None = None,
sampling_approval_callback: SamplingApprovalCallback | None = None,
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
additional_properties: dict[str, Any] | None = None,
http_client: AsyncClient | None = None,
header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None,
task_options: MCPTaskOptions | None = None,
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP streamable HTTP tool.
@@ -2338,6 +2570,16 @@ class MCPStreamableHTTPTool(MCPTool):
additional_properties: Additional properties.
terminate_on_close: Close the transport when the MCP client is terminated.
client: The chat client to use for sampling.
sampling_approval_callback: Optional gate run before each server-initiated
``sampling/createMessage`` request reaches ``client``. Receives the raw
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
request, a falsy return denies it. When ``None`` (the default) every sampling
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
(``min(requested, cap)``); ``None`` disables it.
sampling_max_requests: Per-session cap on the number of sampling requests; further
requests are rejected. Resets on reconnect. ``None`` disables it.
http_client: Optional asyncClient to use. If not provided, the
``streamable_http_client`` API will create and manage a default client.
To configure headers, timeouts, or other HTTP client settings, create
@@ -2349,6 +2591,20 @@ class MCPStreamableHTTPTool(MCPTool):
agent middleware) without creating a separate ``httpx.AsyncClient``.
task_options: Options for tools that advertise
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
additional_tool_argument_names: Extra argument names to forward to the MCP server in
addition to each tool's declared parameters (from its ``inputSchema.properties``).
By default only declared parameters are sent; framework runtime kwargs injected
through the function-invocation pipeline are stripped. Use this to opt specific
keys back in. Accepts either a ``Sequence[str]`` applied to every tool, or a
``Mapping[str, Sequence[str]]`` keyed by remote tool name where the reserved key
``"*"`` applies to every tool. This is configured only here in user code; there is
no per-call override, so a model-issued tool call cannot change which names pass
through. To use a server that accepts ``additionalProperties: true``, list the
extra names here and then either (1) manually extend that tool's ``inputSchema``
(via the ``.functions`` list after connecting) so the model is prompted to supply
them, or (2) supply the values yourself through ``function_invocation_kwargs``. If
a name is supplied via both the model and ``function_invocation_kwargs``, the
model-supplied value wins.
kwargs: Additional keyword arguments (accepted for backward compatibility but not used).
"""
super().__init__(
@@ -2366,6 +2622,10 @@ class MCPStreamableHTTPTool(MCPTool):
parse_prompt_results=parse_prompt_results,
request_timeout=request_timeout,
task_options=task_options,
additional_tool_argument_names=additional_tool_argument_names,
sampling_approval_callback=sampling_approval_callback,
sampling_max_tokens=sampling_max_tokens,
sampling_max_requests=sampling_max_requests,
)
self.url = url
self.terminate_on_close = terminate_on_close
@@ -2490,8 +2750,12 @@ class MCPWebsocketTool(MCPTool):
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
allowed_tools: Collection[str] | None = None,
client: SupportsChatGetResponse | None = None,
sampling_approval_callback: SamplingApprovalCallback | None = None,
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
additional_properties: dict[str, Any] | None = None,
task_options: MCPTaskOptions | None = None,
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP WebSocket tool.
@@ -2534,8 +2798,32 @@ class MCPWebsocketTool(MCPTool):
allowed_tools: A list of tools that are allowed to use this tool.
additional_properties: Additional properties.
client: The chat client to use for sampling.
sampling_approval_callback: Optional gate run before each server-initiated
``sampling/createMessage`` request reaches ``client``. Receives the raw
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
request, a falsy return denies it. When ``None`` (the default) every sampling
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
(``min(requested, cap)``); ``None`` disables it.
sampling_max_requests: Per-session cap on the number of sampling requests; further
requests are rejected. Resets on reconnect. ``None`` disables it.
task_options: Options for tools that advertise
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
additional_tool_argument_names: Extra argument names to forward to the MCP server in
addition to each tool's declared parameters (from its ``inputSchema.properties``).
By default only declared parameters are sent; framework runtime kwargs injected
through the function-invocation pipeline are stripped. Use this to opt specific
keys back in. Accepts either a ``Sequence[str]`` applied to every tool, or a
``Mapping[str, Sequence[str]]`` keyed by remote tool name where the reserved key
``"*"`` applies to every tool. This is configured only here in user code; there is
no per-call override, so a model-issued tool call cannot change which names pass
through. To use a server that accepts ``additionalProperties: true``, list the
extra names here and then either (1) manually extend that tool's ``inputSchema``
(via the ``.functions`` list after connecting) so the model is prompted to supply
them, or (2) supply the values yourself through ``function_invocation_kwargs``. If
a name is supplied via both the model and ``function_invocation_kwargs``, the
model-supplied value wins.
kwargs: Any extra arguments to pass to the WebSocket client.
"""
super().__init__(
@@ -2553,6 +2841,10 @@ class MCPWebsocketTool(MCPTool):
parse_prompt_results=parse_prompt_results,
request_timeout=request_timeout,
task_options=task_options,
additional_tool_argument_names=additional_tool_argument_names,
sampling_approval_callback=sampling_approval_callback,
sampling_max_tokens=sampling_max_tokens,
sampling_max_requests=sampling_max_requests,
)
self.url = url
self._client_kwargs = kwargs

Some files were not shown because too many files have changed in this diff Show More