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
90 changed files with 4919 additions and 676 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/
@@ -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>
@@ -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>
@@ -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);
}
@@ -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) =>
@@ -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);
}
}
}
@@ -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")
@@ -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
}
+1
View File
@@ -82,6 +82,7 @@ agent_framework/
- **`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,
+184 -8
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
@@ -99,6 +100,22 @@ _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextV
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] = {
@@ -345,6 +362,9 @@ 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,
@@ -378,6 +398,20 @@ 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``,
@@ -410,6 +444,10 @@ 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] = {}
@@ -539,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
@@ -840,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
@@ -994,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],
@@ -1001,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
@@ -1023,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))
@@ -1045,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
@@ -2219,6 +2347,9 @@ 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,
@@ -2266,6 +2397,16 @@ 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
@@ -2300,6 +2441,9 @@ class MCPStdioTool(MCPTool):
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 []
@@ -2375,6 +2519,9 @@ 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,
@@ -2423,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
@@ -2466,6 +2623,9 @@ class MCPStreamableHTTPTool(MCPTool):
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
@@ -2590,6 +2750,9 @@ 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,
@@ -2635,6 +2798,16 @@ 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
@@ -2669,6 +2842,9 @@ class MCPWebsocketTool(MCPTool):
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
@@ -3516,9 +3516,7 @@ class MCPSkill(Skill):
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
text = _mcp_join_text(result)
if not text:
raise ValueError(
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
)
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
self._content = text
return text
@@ -3572,11 +3570,7 @@ class MCPSkill(Skill):
or ``None`` if the name is unsafe.
"""
normalized = name.replace("\\", "/")
if (
normalized.startswith("/")
or "://" in normalized
or any(seg == ".." for seg in normalized.split("/"))
):
if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")):
logger.debug("Rejecting resource name with unsafe path components: %r", name)
return None
return normalized
@@ -194,6 +194,63 @@ def test_create_harness_agent_returns_full_agent() -> None:
assert isinstance(agent, FullAgent)
def test_create_harness_agent_no_token_params_disables_compaction() -> None:
"""When token params are omitted, compaction is automatically disabled."""
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
)
provider_types = [type(p) for p in agent.context_providers]
assert CompactionProvider not in provider_types
def test_create_harness_agent_no_token_params_skips_max_tokens_option() -> None:
"""When max_output_tokens is omitted, max_tokens should not be set in default options."""
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
)
assert agent.default_options.get("max_tokens") is None
def test_create_harness_agent_custom_before_strategy_enables_compaction_without_tokens() -> None:
"""A custom before_compaction_strategy enables compaction even when token params are omitted."""
from agent_framework import ToolResultCompactionStrategy
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
before_compaction_strategy=ToolResultCompactionStrategy(),
)
provider_types = [type(p) for p in agent.context_providers]
assert CompactionProvider in provider_types
def test_create_harness_agent_disable_compaction_overrides_custom_before_strategy() -> None:
"""disable_compaction=True wins even when a custom before strategy is provided."""
from agent_framework import ToolResultCompactionStrategy
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
before_compaction_strategy=ToolResultCompactionStrategy(),
disable_compaction=True,
)
provider_types = [type(p) for p in agent.context_providers]
assert CompactionProvider not in provider_types
def test_create_harness_agent_custom_after_strategy_enables_compaction_without_tokens() -> None:
"""A custom after_compaction_strategy enables compaction even when token params are omitted."""
from agent_framework import ToolResultCompactionStrategy
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
after_compaction_strategy=ToolResultCompactionStrategy(),
)
compaction_providers = [p for p in agent.context_providers if isinstance(p, CompactionProvider)]
assert len(compaction_providers) == 1
# Before phase is skipped (no token budget, no custom before strategy), after phase is set.
assert compaction_providers[0].before_strategy is None
assert compaction_providers[0].after_strategy is not None
# --- Validation Tests ---
@@ -207,14 +264,15 @@ def test_create_harness_agent_rejects_invalid_context_tokens() -> None:
)
def test_create_harness_agent_rejects_negative_output_tokens() -> None:
"""max_output_tokens must be non-negative."""
with pytest.raises(ValueError, match="max_output_tokens must be non-negative"):
create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=1000,
max_output_tokens=-1,
)
def test_create_harness_agent_rejects_non_positive_output_tokens() -> None:
"""max_output_tokens must be positive when provided."""
for invalid_value in (0, -1):
with pytest.raises(ValueError, match="max_output_tokens must be positive"):
create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=1000,
max_output_tokens=invalid_value,
)
def test_create_harness_agent_rejects_output_gte_context() -> None:
+276 -20
View File
@@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
assert result[1].text == "Embedded result"
def test_parse_tool_result_from_mcp_structured_content_only():
"""Test that structuredContent is parsed when content list is empty."""
mcp_result = types.CallToolResult(
content=[],
structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]},
)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
assert result[0].type == "text"
parsed = json.loads(result[0].text)
assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}
def test_parse_tool_result_from_mcp_structured_content_with_text():
"""Test that structuredContent is appended alongside regular content items."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Summary")],
structuredContent={"data": [1, 2, 3]},
)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 2
assert result[0].type == "text"
assert result[0].text == "Summary"
assert result[1].type == "text"
parsed = json.loads(result[1].text)
assert parsed == {"data": [1, 2, 3]}
def test_parse_tool_result_from_mcp_structured_content_none():
"""Test that None structuredContent does not affect results."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Hello")],
structuredContent=None,
)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
assert result[0].type == "text"
assert result[0].text == "Hello"
def test_parse_tool_result_from_mcp_structured_content_non_serializable():
"""Test that non-JSON-serializable values in structuredContent degrade gracefully."""
mcp_result = types.CallToolResult(
content=[],
structuredContent={"data": b"raw bytes", "count": 42},
)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
assert result[0].type == "text"
parsed = json.loads(result[0].text)
assert parsed["count"] == 42
# bytes should be converted to string representation via default=str
assert "raw bytes" in parsed["data"]
def test_mcp_content_types_to_ai_content_text():
"""Test conversion of MCP text content to AI content."""
mcp_content = types.TextContent(type="text", text="Sample text")
@@ -1813,6 +1876,18 @@ async def test_mcp_tool_message_handler_cancel_and_replace():
assert len(tool._pending_reload_tasks) == 0
def _approve(_params: object) -> bool:
"""Approving sampling gate used by tests that exercise forwarding behavior."""
return True
def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock:
mock_response = Mock()
mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])]
mock_response.model = model
return mock_response
async def test_mcp_tool_sampling_callback_no_client():
"""Test sampling callback error path when no chat client is available."""
tool = MCPStdioTool(name="test_tool", command="python")
@@ -1828,9 +1903,190 @@ async def test_mcp_tool_sampling_callback_no_client():
assert "No chat client available" in result.message
async def test_mcp_tool_sampling_callback_denies_by_default():
"""Sampling is denied when no approval callback is configured (safe default)."""
tool = MCPStdioTool(name="test_tool", command="python")
mock_chat_client = AsyncMock()
tool.client = mock_chat_client
params = Mock()
params.messages = []
params.maxTokens = 128
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.ErrorData)
assert result.code == types.INVALID_REQUEST
assert "denied" in result.message
assert "sampling_approval_callback" in result.message
mock_chat_client.get_response.assert_not_called()
async def test_mcp_tool_sampling_callback_denied_by_callback():
"""Sampling is denied when the approval callback returns a falsy value."""
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=lambda params: False)
mock_chat_client = AsyncMock()
tool.client = mock_chat_client
params = Mock()
params.messages = []
params.maxTokens = 128
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.ErrorData)
assert result.code == types.INVALID_REQUEST
assert "denied by the 'sampling_approval_callback'" in result.message
mock_chat_client.get_response.assert_not_called()
async def test_mcp_tool_sampling_callback_callback_exception_denies():
"""An approval callback that raises results in denial, not an LLM call."""
def boom(_params: object) -> bool:
raise RuntimeError("approval error")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=boom)
mock_chat_client = AsyncMock()
tool.client = mock_chat_client
params = Mock()
params.messages = []
params.maxTokens = 128
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.ErrorData)
assert result.code == types.INVALID_REQUEST
mock_chat_client.get_response.assert_not_called()
async def test_mcp_tool_sampling_callback_async_approval():
"""An async approval callback that approves allows the request through."""
async def approve(_params: object) -> bool:
return True
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=approve)
mock_chat_client = AsyncMock()
mock_chat_client.get_response.return_value = _make_sampling_response("ok")
tool.client = mock_chat_client
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
assert result.content.text == "ok"
mock_chat_client.get_response.assert_awaited_once()
async def test_mcp_tool_sampling_callback_clamps_max_tokens():
"""An approved request's maxTokens is clamped to sampling_max_tokens."""
tool = MCPStdioTool(
name="test_tool",
command="python",
sampling_approval_callback=_approve,
sampling_max_tokens=512,
)
mock_chat_client = AsyncMock()
mock_chat_client.get_response.return_value = _make_sampling_response()
tool.client = mock_chat_client
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = 1_000_000
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
assert options["max_tokens"] == 512
async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap():
"""A request below the cap keeps its requested maxTokens."""
tool = MCPStdioTool(
name="test_tool",
command="python",
sampling_approval_callback=_approve,
sampling_max_tokens=512,
)
mock_chat_client = AsyncMock()
mock_chat_client.get_response.return_value = _make_sampling_response()
tool.client = mock_chat_client
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
result = await tool.sampling_callback(Mock(), params)
assert isinstance(result, types.CreateMessageResult)
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
assert options["max_tokens"] == 100
async def test_mcp_tool_sampling_callback_rate_limited():
"""Sampling requests beyond sampling_max_requests are rejected per session."""
tool = MCPStdioTool(
name="test_tool",
command="python",
sampling_approval_callback=_approve,
sampling_max_requests=2,
)
mock_chat_client = AsyncMock()
mock_chat_client.get_response.return_value = _make_sampling_response()
tool.client = mock_chat_client
def make_params() -> Mock:
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
params.toolChoice = None
return params
first = await tool.sampling_callback(Mock(), make_params())
second = await tool.sampling_callback(Mock(), make_params())
third = await tool.sampling_callback(Mock(), make_params())
assert isinstance(first, types.CreateMessageResult)
assert isinstance(second, types.CreateMessageResult)
assert isinstance(third, types.ErrorData)
assert third.code == types.INVALID_REQUEST
assert "rate limit" in third.message.lower()
assert mock_chat_client.get_response.await_count == 2
# The counter resets on a session reset.
tool._reset_session_state()
fourth = await tool.sampling_callback(Mock(), make_params())
assert isinstance(fourth, types.CreateMessageResult)
async def test_mcp_tool_sampling_callback_chat_client_exception():
"""Test sampling callback when chat client raises exception."""
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
# Mock chat client that raises exception
mock_chat_client = AsyncMock()
@@ -1846,7 +2102,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
@@ -1863,7 +2119,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
"""Test sampling callback when response has no valid content types."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
# Mock chat client with response containing only invalid content types
mock_chat_client = AsyncMock()
@@ -1892,7 +2148,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
@@ -1905,18 +2161,18 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
assert "Failed to get right content types from the response." in result.message
mock_chat_client.get_response.assert_awaited_once()
_, kwargs = mock_chat_client.get_response.await_args
assert kwargs["options"] == {"max_tokens": None}
assert kwargs["options"] == {"max_tokens": 100}
async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation():
"""Test sampling callback when the chat client returns no response and then valid content."""
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
tool.client = AsyncMock()
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
@@ -1955,7 +2211,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
"""Test sampling callback passes systemPrompt as instructions in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -1972,7 +2228,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = "You are a helpful assistant"
params.tools = None
@@ -1990,7 +2246,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
"""Test sampling callback converts MCP tools to FunctionTools and passes them in options."""
from agent_framework import FunctionTool, Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2013,7 +2269,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = [mcp_tool]
@@ -2036,7 +2292,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
"""Test sampling callback passes toolChoice mode in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2053,7 +2309,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = None
@@ -2071,7 +2327,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
"""Test sampling callback forwards empty string systemPrompt as instructions."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2088,7 +2344,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = ""
params.tools = None
@@ -2106,7 +2362,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
"""Test sampling callback forwards empty tools list in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2123,7 +2379,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
mock_message.content.text = "Test question"
params.messages = [mock_message]
params.temperature = None
params.maxTokens = None
params.maxTokens = 100
params.stopSequences = None
params.systemPrompt = None
params.tools = []
@@ -2141,7 +2397,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(
"""Test sampling callback passes temperature, max_tokens, and stop in options."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2182,7 +2438,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none():
"""Test sampling callback does not set temperature in options when it is None."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -2219,7 +2475,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens():
"""Test sampling callback always sets max_tokens in options since maxTokens is a required int field."""
from agent_framework import Message
tool = MCPStdioTool(name="test_tool", command="python")
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
mock_chat_client = AsyncMock()
mock_response = Mock()
@@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock
result = Mock()
result.isError = is_error
result.content = [types.TextContent(type="text", text=text)]
result.structuredContent = None
return result
@@ -281,9 +282,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
tool = _make_connected_mcp_tool()
tool.session.get_prompt = AsyncMock(
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
)
tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found")))
span_exporter.clear()
with pytest.raises(ToolExecutionException):
@@ -35,26 +35,22 @@ description: Convert between common units.
Body content here.
"""
SAMPLE_SKILL_INDEX = json.dumps(
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://unit-converter/SKILL.md",
}
],
}
)
SAMPLE_SKILL_INDEX = json.dumps({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://unit-converter/SKILL.md",
}
],
})
def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult:
"""Create a ReadResourceResult with a single TextResourceContents."""
return ReadResourceResult(
contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]
)
return ReadResourceResult(contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")])
def _make_blob_result(
@@ -230,12 +226,10 @@ class TestMCPSkill:
@pytest.mark.asyncio
async def test_get_resource_text(self) -> None:
client = _make_client(
**{
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
}
)
client = _make_client(**{
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
})
from agent_framework import SkillFrontmatter
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
@@ -249,12 +243,10 @@ class TestMCPSkill:
@pytest.mark.asyncio
async def test_get_resource_binary(self) -> None:
data = bytes([0x01, 0x02, 0x03, 0x04])
client = _make_client(
**{
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
}
)
client = _make_client(**{
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
})
from agent_framework import SkillFrontmatter
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
@@ -345,12 +337,10 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_index_based_discovery_returns_skill(self) -> None:
client = _make_client(
**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
}
)
client = _make_client(**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -373,9 +363,7 @@ class TestMCPSkillsSource:
async def test_does_not_read_skill_md_during_discovery(self) -> None:
# Index points to a skill, but SKILL.md is not registered on the server.
# Discovery should succeed because it only reads the index.
client = _make_client(
**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}
)
client = _make_client(**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -384,19 +372,17 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_invalid_name_is_skipped(self) -> None:
index_json = json.dumps(
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "UnitConverter", # Invalid: uppercase
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://UnitConverter/SKILL.md",
}
],
}
)
index_json = json.dumps({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "UnitConverter", # Invalid: uppercase
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://UnitConverter/SKILL.md",
}
],
})
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -404,18 +390,16 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_missing_required_fields_is_skipped(self) -> None:
index_json = json.dumps(
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
# Missing description and url
}
],
}
)
index_json = json.dumps({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
# Missing description and url
}
],
})
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -423,19 +407,17 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_unsupported_type_is_skipped(self) -> None:
index_json = json.dumps(
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "some-skill",
"type": "archive",
"description": "Packaged skill.",
"url": "skill://some-skill.tar.gz",
}
],
}
)
index_json = json.dumps({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "some-skill",
"type": "archive",
"description": "Packaged skill.",
"url": "skill://some-skill.tar.gz",
}
],
})
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -443,18 +425,16 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_template_type_is_skipped(self) -> None:
index_json = json.dumps(
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"type": "mcp-resource-template",
"description": "Per-product documentation skill",
"url": "skill://docs/{product}/SKILL.md",
}
],
}
)
index_json = json.dumps({
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"type": "mcp-resource-template",
"description": "Per-product documentation skill",
"url": "skill://docs/{product}/SKILL.md",
}
],
})
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
@@ -462,31 +442,25 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_empty_index_returns_empty(self) -> None:
client = _make_client(
**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}
)
client = _make_client(**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
assert skills == []
@pytest.mark.asyncio
async def test_malformed_index_json_returns_empty(self) -> None:
client = _make_client(
**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}
)
client = _make_client(**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")})
source = MCPSkillsSource(client=client)
skills = await source.get_skills()
assert skills == []
@pytest.mark.asyncio
async def test_sibling_text_resource(self) -> None:
client = _make_client(
**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
}
)
client = _make_client(**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
})
source = MCPSkillsSource(client=client)
skill = (await source.get_skills())[0]
resource = await skill.get_resource("references/checklist.md")
@@ -497,13 +471,11 @@ class TestMCPSkillsSource:
@pytest.mark.asyncio
async def test_sibling_binary_resource(self) -> None:
data = bytes([0x01, 0x02, 0x03, 0x04])
client = _make_client(
**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
}
)
client = _make_client(**{
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
})
source = MCPSkillsSource(client=client)
skill = (await source.get_skills())[0]
resource = await skill.get_resource("assets/icon.bin")
@@ -649,9 +621,7 @@ class TestMCPSkillsSourceErrorCodeBranching:
from agent_framework import SkillFrontmatter
client = AsyncMock()
client.read_resource = AsyncMock(
side_effect=McpError(error=ErrorData(code=0, message="Handler error"))
)
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Handler error")))
fm = SkillFrontmatter(name="test-skill", description="Test.")
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
with pytest.raises(McpError):
@@ -63,6 +63,9 @@ logger = logging.getLogger(__name__)
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
# Allowed identifier shape for object-attribute steps in declarative state paths
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
@dataclass(frozen=True)
class DeclarativeEnvConfig:
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
- Conversation: Conversation history
"""
# Sentinel marking "no prior value" for temporary-key bookkeeping.
_MISSING: Any = object()
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
"""Initialize with a State instance.
@@ -331,16 +337,21 @@ class DeclarativeWorkflowState:
def get(self, path: str, default: Any = None) -> Any:
"""Get a value from the state using a dot-notated path.
Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
``System.conversations.<id>.messages``). Segments that would resolve
via object-attribute access must be valid declarative identifiers
(``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
Args:
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
default: Default value if path doesn't exist
Returns:
The value at the path, or default if not found
The value at the path, or default if not found or unreachable.
"""
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
if not parts or any(not p for p in parts):
return default
namespace = parts[0]
@@ -377,10 +388,19 @@ class DeclarativeWorkflowState:
obj = obj.get(part, default) # type: ignore[union-attr]
if obj is default:
return default
elif hasattr(obj, part): # type: ignore[arg-type]
obj = getattr(obj, part) # type: ignore[arg-type]
else:
return default
# Attribute access is only allowed for safe declarative identifiers.
if not _SAFE_PATH_SEGMENT_RE.match(part):
logger.warning(
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
part,
path,
)
return default
if hasattr(obj, part): # type: ignore[arg-type]
obj = getattr(obj, part) # type: ignore[arg-type]
else:
return default
return obj # type: ignore[return-value]
@@ -392,12 +412,14 @@ class DeclarativeWorkflowState:
value: The value to set
Raises:
ValueError: If attempting to set Workflow.Inputs (which is read-only)
ValueError: If ``path`` is empty or contains empty segments
(e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
``Workflow.Inputs`` (which is read-only).
"""
state_data = self.get_state_data()
parts = path.split(".")
if not parts:
return
if not parts or any(not p for p in parts):
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
namespace = parts[0]
remaining = parts[1:]
@@ -453,7 +475,16 @@ class DeclarativeWorkflowState:
Args:
path: Dot-notated path to a list
value: The value to append
Raises:
ValueError: If ``path`` is empty or contains empty segments
(e.g. ``"Local."``, ``"Local..foo"``), or if the existing
value at ``path`` is not a list.
"""
parts = path.split(".")
if not parts or any(not p for p in parts):
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
existing = self.get(path)
if existing is None:
self.set(path, [value])
@@ -464,6 +495,15 @@ class DeclarativeWorkflowState:
else:
raise ValueError(f"Cannot append to non-list at path '{path}'")
def _clear_local_path(self, name: str) -> None:
"""Remove ``name`` from the ``Local`` namespace, if present."""
state_data = self.get_state_data()
local = state_data.get("Local")
if local is None or name not in local:
return
local.pop(name, None)
self.set_state_data(state_data)
def eval(self, expression: str) -> Any:
"""Evaluate a PowerFx expression with the current state.
@@ -504,53 +544,64 @@ class DeclarativeWorkflowState:
return result
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
# Replace them with their evaluated results before sending to PowerFx
formula = self._preprocess_custom_functions(formula)
# and run PowerFx. The finally below restores any temporary state
# written during preprocessing, regardless of where execution exits.
temp_writes: list[tuple[str, Any]] = []
if Engine is None:
raise RuntimeError(
f"PowerFx is not available (dotnet runtime not installed). "
f"Expression '={formula[:80]}' cannot be evaluated. "
f"Install dotnet and the powerfx package for full PowerFx support."
)
symbols = self._to_powerfx_symbols()
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
try:
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
break
except locale.Error:
continue
formula = self._preprocess_custom_functions(formula, temp_writes)
engine = Engine()
try:
from System.Globalization import ( # pyright: ignore[reportMissingImports]
CultureInfo, # pyright: ignore[reportUnknownVariableType]
if Engine is None:
raise RuntimeError(
f"PowerFx is not available (dotnet runtime not installed). "
f"Expression '={formula[:80]}' cannot be evaluated. "
f"Install dotnet and the powerfx package for full PowerFx support."
)
except ImportError:
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
symbols = self._to_powerfx_symbols()
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
try:
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
break
except locale.Error:
continue
engine = Engine()
try:
from System.Globalization import ( # pyright: ignore[reportMissingImports]
CultureInfo, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
try:
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
finally:
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
except ValueError as e:
error_msg = str(e)
# Handle undefined variable errors gracefully by returning None
# This matches the behavior of the legacy fallback parser
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
return None
raise
finally:
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
except ValueError as e:
error_msg = str(e)
# Handle undefined variable errors gracefully by returning None
# This matches the behavior of the legacy fallback parser
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
return None
raise
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
finally:
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
# Restore each temporary key to its prior value (or remove it).
for path, previous in reversed(temp_writes):
if previous is self._MISSING:
self._clear_local_path(path.removeprefix("Local."))
else:
self.set(path, previous)
def _eval_custom_function(self, formula: str) -> Any | None:
"""Handle custom functions not supported by the Python PowerFx library.
@@ -609,7 +660,7 @@ class DeclarativeWorkflowState:
return None
def _preprocess_custom_functions(self, formula: str) -> str:
def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
"""Pre-process custom functions nested inside other PowerFx functions.
Custom functions like MessageText() are not supported by the PowerFx engine.
@@ -624,9 +675,14 @@ class DeclarativeWorkflowState:
Args:
formula: The PowerFx formula to pre-process
temp_writes: Caller-owned list. Each write to a temporary key
appends a ``(path, previous_value)`` entry where
``previous_value`` is the value at ``path`` before the write
or :attr:`_MISSING` if none. The caller must restore every
entry, including when this method raises mid-write.
Returns:
The formula with custom function calls replaced by their evaluated results
The rewritten formula.
"""
import re
@@ -635,7 +691,6 @@ class DeclarativeWorkflowState:
# We use 500 to leave room for the rest of the expression around the replaced value.
MAX_INLINE_LENGTH = 500
# Counter for generating unique temp variable names
temp_var_counter = 0
# Custom functions that need pre-processing: (regex pattern, handler)
@@ -691,11 +746,14 @@ class DeclarativeWorkflowState:
# Replace in formula
if isinstance(replacement, str):
if len(replacement) > MAX_INLINE_LENGTH:
# Store long strings in a temp variable to avoid PowerFx expression limit
# Store long results in an underscore-prefixed temp key;
# record the prior value so eval() can restore it.
temp_var_name = f"_TempMessageText{temp_var_counter}"
temp_var_counter += 1
self.set(f"Local.{temp_var_name}", replacement)
replacement_str = f"Local.{temp_var_name}"
temp_var_path = f"Local.{temp_var_name}"
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
self.set(temp_var_path, replacement)
replacement_str = temp_var_path
logger.debug(
f"Stored long MessageText result ({len(replacement)} chars) "
f"in temp variable {temp_var_name}"
@@ -847,11 +905,13 @@ class DeclarativeWorkflowState:
return value
def interpolate_string(self, text: str) -> str:
"""Interpolate {Variable.Path} references in a string.
"""Interpolate ``{Variable.Path}`` references in a string.
This handles template-style variable substitution like:
- "Created ticket #{Local.TicketParameters.TicketId}"
- "Routing to {Local.RoutingParameters.TeamName}"
Captures brace-delimited tokens whose root segment is an identifier
(``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
dict-key segments. Resolution is delegated to :meth:`get`; unresolved
tokens are replaced with the empty string. Tokens that do not look
like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
Args:
text: Text that may contain {Variable.Path} references
@@ -866,10 +926,11 @@ class DeclarativeWorkflowState:
value = self.get(var_path)
return str(value) if value is not None else ""
# Match {Variable.Path} patterns
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
# Root segment must be an identifier; follow-on segments accept any
# non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
# per-segment safety on attribute traversal.
pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
# Replace all matches
result = text
for match in re.finditer(pattern, text):
replacement = replace_var(match)
@@ -0,0 +1,364 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Path-segment validation tests for DeclarativeWorkflowState.
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
placeholders in ``interpolate_string`` are subject to three distinct rules
that this module pins:
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
``interpolate_string`` return their default / leave the placeholder literal;
``set`` and ``append`` raise ``ValueError``.
- **Object-attribute segments** — segments that ``get`` would resolve via
``getattr`` because the parent is a non-dict object — must match the safe
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
warning log and the default is returned.
- **Dict-keyed segments** — segments that resolve via dict lookup because the
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
"""
import logging
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows import DeclarativeWorkflowState
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock for the underlying State."""
ms = MagicMock()
ms._data = {}
def get(key: str, default: Any = None) -> Any:
return ms._data.get(key, default)
def set_(key: str, value: Any) -> None:
ms._data[key] = value
def has(key: str) -> bool:
return key in ms._data
def delete(key: str) -> None:
ms._data.pop(key, None)
ms.get = MagicMock(side_effect=get)
ms.set = MagicMock(side_effect=set_)
ms.has = MagicMock(side_effect=has)
ms.delete = MagicMock(side_effect=delete)
return ms
@pytest.fixture
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
s = DeclarativeWorkflowState(mock_state)
s.initialize()
return s
@dataclass
class _PlainObj:
"""Non-dict object so ``get`` falls through to attribute access."""
text: str = "hi"
# ---------------------------------------------------------------------------
# get(): invalid paths return default
# ---------------------------------------------------------------------------
class TestGetRejectsInvalidPaths:
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.__class__") is None
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-path-safety-sentinel"
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
assert result is None
assert sentinel not in str(result)
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj._private") is None
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.text bar") is None
assert state.get("Local.obj.text-bar") is None
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
assert state.get("") is None
assert state.get(".") is None
assert state.get("Local.") is None
assert state.get(".Local") is None
def test_warning_logged_on_rejected_attribute_segment(
self,
state: DeclarativeWorkflowState,
caplog: pytest.LogCaptureFixture,
) -> None:
state.set("Local.obj", _PlainObj())
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
state.get("Local.obj.__class__")
assert any("rejecting attribute segment" in r.message for r in caplog.records)
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
state.set("Local.bag", {"__class__": "harmless-string"})
assert state.get("Local.bag.__class__") == "harmless-string"
# ---------------------------------------------------------------------------
# get(): legitimate paths continue to work
# ---------------------------------------------------------------------------
class TestGetAllowsValidPaths:
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.UserInput", "u1")
state.set("Local.userInput", "u2")
assert state.get("Local.UserInput") == "u1"
assert state.get("Local.userInput") == "u2"
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.msg", _PlainObj(text="hello"))
assert state.get("Local.msg.text") == "hello"
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": {"name": "alpha"}})
assert state.get("Local.params.team.name") == "alpha"
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
# ---------------------------------------------------------------------------
# set() / append(): dict-keyed operations accept arbitrary string keys
# ---------------------------------------------------------------------------
class TestSetAndAppend:
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-test-1"
state.set(f"System.conversations.{conv_id}.messages", [])
assert state.get(f"System.conversations.{conv_id}.messages") == []
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-42"
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
msgs = state.get(f"System.conversations.{conv_id}.messages")
assert msgs == [{"role": "user", "text": "hi"}]
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
with pytest.raises(ValueError, match="read-only"):
state.set("Workflow.Inputs.x", 1)
# ---------------------------------------------------------------------------
# set() / append(): malformed paths (empty segments) raise ValueError
# ---------------------------------------------------------------------------
class TestSetRejectsInvalidPaths:
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.set(bad_path, "x")
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.append(bad_path, "x")
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected set() must not create an unreachable entry in the state."""
state.set("Local.user_input", "pre")
with pytest.raises(ValueError):
state.set("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"user_input": "pre"}
assert state.get("Local.") is None
assert state.get("Local.user_input") == "pre"
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected append() must not create an unreachable entry in the state."""
state.set("Local.items", ["a"])
with pytest.raises(ValueError):
state.append("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"items": ["a"]}
# ---------------------------------------------------------------------------
# interpolate_string(): permissive matcher; get() enforces safety
# ---------------------------------------------------------------------------
class TestInterpolateString:
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-interp-sentinel"
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
assert sentinel not in out
assert out == "X="
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
assert state.interpolate_string("v={Local._private}") == "v="
@pytest.mark.parametrize(
"literal",
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
)
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "hello")
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": "alpha"})
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
@pytest.mark.parametrize(
("key", "value"),
[
("_id", "abc123"),
("1", "one"),
("2025", "year-bucket"),
],
)
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
state.set("Local.bag", {key: value})
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
assert out == "m=['hello']"
def test_end_to_end_send_activity_payload_neutralized(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
sentinel = "agent-framework-e2e-sentinel"
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
state.set("Local.toolResult", _PlainObj())
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
evaluated = state.eval_if_expression(payload)
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
assert sentinel not in rendered
assert rendered == ""
# ---------------------------------------------------------------------------
# Regressions: PowerFx and internal temp-variable handling still work
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxStillWorks:
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.x", 6)
state.set("Local.y", 7)
assert state.eval("=Local.x * Local.y") == 42
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
"""Long MessageText() results round-trip and the temp key is removed after eval."""
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
"""User state at the temp key path survives a long MessageText eval."""
long_text = "A" * 600
state.set("Local._TempMessageText0", "user-important-value")
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
assert state.get("Local._TempMessageText0") == "user-important-value"
def test_message_text_eval_cleans_up_on_powerfx_failure(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
"""Temp key is removed even when PowerFx evaluation raises."""
from agent_framework_declarative._workflows import _declarative_base as base
class _FailingEngine:
def eval(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(base, "Engine", _FailingEngine)
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
with pytest.raises(RuntimeError, match="boom"):
state.eval("=Upper(MessageText(Local.Messages))")
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
@@ -2765,7 +2765,7 @@ class TestLongMessageTextHandling:
assert temp_var is None
async def test_long_message_text_stored_in_temp_variable(self, mock_state):
"""Test that long MessageText results are stored in temp variables."""
"""Long MessageText results round-trip and the temp key is removed after eval."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
@@ -2777,9 +2777,9 @@ class TestLongMessageTextHandling:
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600 # Upper on 'A' is still 'A'
# A temp variable should have been created
temp_var = state.get("Local._TempMessageText0")
assert temp_var == long_text
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
async def test_find_with_long_message_text(self, mock_state):
"""Test Find function works with long MessageText stored in temp variable."""
@@ -198,12 +198,7 @@ class TestBeforeRun:
"""OSS client with all scoping parameters passes them as isolated concurrent kwargs."""
mock_oss_mem0_client.search.return_value = []
provider = Mem0ContextProvider(
source_id="mem0",
mem0_client=mock_oss_mem0_client,
user_id="u1",
agent_id="a1"
)
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1")
mock_context = MagicMock(spec=SessionContext)
mock_msg = MagicMock()
+1
View File
@@ -320,4 +320,5 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError,
- **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses.
- **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request.
- **Caching**: Protection scopes responses and 402 errors are cached by default with a 4-hour TTL. Cache is automatically invalidated when protection scope state changes.
- **Cold-cache parallelization**: On a `ProtectionScopes` cache miss, scopes are refreshed in the background while `ProcessContent` runs in the foreground.
- **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow.
@@ -231,18 +231,19 @@ class ScopedContentProcessor:
cached_ps_resp = await self._cache.get(cache_key)
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
ps_resp = cached_ps_resp
else:
ttl = self._settings.get("cache_ttl_seconds")
ttl_seconds = ttl if ttl is not None else 14400
try:
ps_resp = await self._client.get_protection_scopes(ps_req)
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
except PurviewPaymentRequiredError as ex:
# Cache the exception at tenant level so all subsequent requests for this tenant fail fast
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
raise
return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key)
task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return await self._call_process_content(pc_request, cache_key, dlp_actions=[])
async def _process_with_cached_scopes(
self,
pc_request: ProcessContentRequest,
ps_resp: ProtectionScopesResponse,
cache_key: str,
) -> ProcessContentResponse:
if ps_resp.scope_identifier:
pc_request.scope_identifier = ps_resp.scope_identifier
@@ -259,13 +260,7 @@ class ScopedContentProcessor:
task.add_done_callback(self._background_tasks.discard)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
pc_resp = await self._client.process_content(pc_request)
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
return pc_resp
return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions)
# No applicable scopes - send content activities in background
ca_req = ContentActivitiesRequest(
@@ -281,12 +276,52 @@ class ScopedContentProcessor:
# Respond with HttpStatusCode 204(No Content)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
async def _call_process_content(
self,
pc_request: ProcessContentRequest,
cache_key: str,
dlp_actions: list[DlpActionInfo],
) -> ProcessContentResponse:
pc_resp = await self._client.process_content(pc_request)
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
if dlp_actions:
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
return pc_resp
async def _refresh_protection_scopes_background(
self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest
) -> None:
"""Fetch protection scopes and warm the cache without blocking the foreground call."""
ttl = self._settings.get("cache_ttl_seconds")
ttl_seconds = ttl if ttl is not None else 14400
try:
ps_resp = await self._client.get_protection_scopes(ps_req)
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp)
if not should_process:
ca_req = ContentActivitiesRequest(
user_id=pc_request.user_id,
tenant_id=pc_request.tenant_id,
content_to_process=pc_request.content_to_process,
correlation_id=pc_request.correlation_id,
)
await self._send_content_activities_background(ca_req)
except PurviewPaymentRequiredError as ex:
tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}"
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
logger.warning("Background protection scopes refresh failed with payment required: %s", ex)
except Exception as ex:
logger.warning("Background protection scopes refresh failed: %s", ex)
async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None:
"""Process content in background for offline execution mode."""
try:
pc_resp = await self._client.process_content(pc_request)
# If protection scope state is modified, make another PC request and invalidate cache
# If protection scopes changed, invalidate cache and retry once.
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
await self._client.process_content(pc_request)
@@ -306,14 +341,10 @@ class ScopedContentProcessor:
def _combine_policy_actions(
existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo]
) -> list[DlpActionInfo]:
by_key: dict[str, DlpActionInfo] = {}
for a in existing or []:
if a.action:
by_key[a.action] = a
for a in new_actions:
if a.action:
by_key[a.action] = a
return list(by_key.values())
combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {}
for action_info in (existing or []) + new_actions:
combined.setdefault((action_info.action, action_info.restriction_action), action_info)
return list(combined.values())
@staticmethod
def _check_applicable_scopes(
@@ -2,6 +2,7 @@
"""Tests for Purview processor."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -217,10 +218,38 @@ class TestScopedContentProcessor:
assert action1 in combined
assert action2 in combined
async def test_combine_policy_actions_preserves_restriction_only_actions(
self, processor: ScopedContentProcessor
) -> None:
"""Test _combine_policy_actions keeps actions that only set restrictionAction."""
existing_action = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER)
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
combined = processor._combine_policy_actions([existing_action], [restriction_only_action])
assert combined == [existing_action, restriction_only_action]
async def test_combine_policy_actions_deduplicates_by_action_and_restriction(
self, processor: ScopedContentProcessor
) -> None:
"""Test _combine_policy_actions removes exact duplicate actions."""
block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK)
duplicate_block_action = DlpActionInfo(
action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK
)
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
combined = processor._combine_policy_actions(
[block_action],
[duplicate_block_action, restriction_only_action],
)
assert combined == [block_action, restriction_only_action]
async def test_process_with_scopes_calls_client_methods(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
"""Test _process_with_scopes calls process_content immediately and warms scopes in background on cache miss."""
from agent_framework_purview._models import (
ContentActivitiesResponse,
ProtectionScopesResponse,
@@ -236,38 +265,91 @@ class TestScopedContentProcessor:
response = await processor._process_with_scopes(request)
mock_client.get_protection_scopes.assert_called_once()
# When no scopes apply, process_content is not called (activities are sent in background)
mock_client.process_content.assert_not_called()
# The response should have id=204 (No Content) when no scopes apply
assert response.id == "204"
# On cache miss, ProcessContent runs in the foreground and the response is returned.
assert response.id == "response-123"
mock_client.process_content.assert_called_once()
async def test_process_with_scopes_ignores_unexpected_cached_value_type(
# Protection scopes are refreshed in a background task.
await asyncio.gather(*list(processor._background_tasks))
mock_client.get_protection_scopes.assert_called_once()
mock_client.send_content_activities.assert_called_once()
async def test_process_with_scopes_preserves_restriction_only_policy_actions(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that a corrupted cache entry does not crash processing."""
"""Test cold-cache ProcessContent actions are not dropped when they only contain restrictionAction."""
from agent_framework_purview._models import ProtectionScopesResponse
request = process_content_request_factory()
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
mock_client.process_content = AsyncMock(
return_value=ProcessContentResponse(
id="response-123",
protection_scope_state="notModified",
policy_actions=[restriction_only_action],
)
)
response = await processor._process_with_scopes(request)
assert response.policy_actions == [restriction_only_action]
await asyncio.gather(*list(processor._background_tasks))
async def test_process_with_cached_scopes_preserves_restriction_only_policy_actions(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test cached ProtectionScopes actions are not dropped when they only contain restrictionAction."""
from agent_framework_purview._models import (
ExecutionMode,
PolicyLocation,
PolicyScope,
ProcessContentResponse,
ProtectionScopeActivities,
ProtectionScopesResponse,
)
request = process_content_request_factory()
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
process_content_action = DlpActionInfo(action=DlpAction.OTHER, restriction_action=RestrictionAction.OTHER)
scope_location = PolicyLocation(
data_type="microsoft.graph.policyLocationApplication",
value="app-id",
)
scope = PolicyScope(
activities=ProtectionScopeActivities.UPLOAD_TEXT,
locations=[scope_location],
policy_actions=[restriction_only_action],
execution_mode=ExecutionMode.EVALUATE_INLINE,
)
# Return a valid, inline scope so we stay on the normal (non-background) path.
scope_location = PolicyLocation(**{
"@odata.type": "microsoft.graph.policyLocationApplication",
"value": "app-id",
})
scope = PolicyScope(**{
"activities": ProtectionScopeActivities.UPLOAD_TEXT,
"locations": [scope_location],
"execution_mode": ExecutionMode.EVALUATE_INLINE,
})
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": [scope]}))
processor._cache.get = AsyncMock(
side_effect=[
None,
ProtectionScopesResponse(scope_identifier="scope-123", scopes=[scope]),
]
) # type: ignore[method-assign]
mock_client.process_content = AsyncMock(
return_value=ProcessContentResponse(
id="response-123",
protection_scope_state="notModified",
policy_actions=[process_content_action],
)
)
response = await processor._process_with_scopes(request)
assert response.policy_actions == [process_content_action, restriction_only_action]
async def test_process_with_scopes_ignores_unexpected_cached_value_type(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that a corrupted cache entry does not crash processing."""
from agent_framework_purview._models import ProtectionScopesResponse
request = process_content_request_factory()
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
mock_client.process_content = AsyncMock(
return_value=ProcessContentResponse(**{"id": "ok", "protectionScopeState": "notModified"})
)
@@ -279,8 +361,9 @@ class TestScopedContentProcessor:
response = await processor._process_with_scopes(request)
assert response.id == "ok"
mock_client.get_protection_scopes.assert_called_once()
mock_client.process_content.assert_called_once()
await asyncio.gather(*list(processor._background_tasks))
mock_client.get_protection_scopes.assert_called_once()
async def test_process_with_scopes_uses_tenant_payment_exception_cache(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
@@ -301,8 +384,6 @@ class TestScopedContentProcessor:
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test offline background processing invalidates cache and retries when scope state changes."""
from agent_framework_purview._models import ProcessContentResponse
request = process_content_request_factory()
request.scope_identifier = "etag-1"
@@ -319,6 +400,36 @@ class TestScopedContentProcessor:
processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc")
assert mock_client.process_content.call_count == 2
async def test_background_scope_refresh_caches_payment_required(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""402 raised during background scope refresh is cached at the tenant level."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
cache = InMemoryCacheProvider()
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache)
mock_client.get_protection_scopes = AsyncMock(side_effect=PurviewPaymentRequiredError("nope"))
mock_client.process_content = AsyncMock(
return_value=ProcessContentResponse(**{"id": "pc-1", "protectionScopeState": "notModified"})
)
request = process_content_request_factory()
await processor._process_with_scopes(request)
await asyncio.gather(*list(processor._background_tasks))
cached = await cache.get(f"purview:payment_required:{request.tenant_id}")
assert isinstance(cached, PurviewPaymentRequiredError)
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
"""Test user_id extraction from message additional_properties."""
settings = PurviewSettings(
@@ -387,6 +498,8 @@ class TestScopedContentProcessor:
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that response is returned when scopes don't apply (activities sent in background)."""
from agent_framework_purview._models import ProtectionScopesResponse
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -398,10 +511,8 @@ class TestScopedContentProcessor:
pc_request = process_content_request_factory()
# Mock get_protection_scopes to return no applicable scopes
mock_ps_response = MagicMock()
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
mock_ps_response = ProtectionScopesResponse(scopes=[])
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
# Mock send_content_activities to return success (called in background)
mock_ca_response = MagicMock()
@@ -410,8 +521,10 @@ class TestScopedContentProcessor:
response = await processor._process_with_scopes(pc_request)
mock_client.get_protection_scopes.assert_called_once()
mock_client.get_protection_scopes.assert_not_called()
mock_client.process_content.assert_not_called()
await asyncio.gather(*list(processor._background_tasks))
mock_client.send_content_activities.assert_called_once()
# Response should have id=204 when no scopes apply
assert response.id == "204"
@@ -419,6 +532,8 @@ class TestScopedContentProcessor:
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that errors in background activities don't affect the response."""
from agent_framework_purview._models import ProtectionScopesResponse
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -430,10 +545,8 @@ class TestScopedContentProcessor:
pc_request = process_content_request_factory()
# Mock get_protection_scopes to return no applicable scopes
mock_ps_response = MagicMock()
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
mock_ps_response = ProtectionScopesResponse(scopes=[])
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
# Mock send_content_activities to return error (called in background task)
mock_ca_response = MagicMock()
@@ -445,6 +558,8 @@ class TestScopedContentProcessor:
# Since activities are sent in background, errors don't affect the response
# Response should have id=204 when no scopes apply
assert response.id == "204"
await asyncio.gather(*list(processor._background_tasks))
mock_client.send_content_activities.assert_called_once()
class TestUserIdResolution:
@@ -656,10 +771,12 @@ class TestScopedContentProcessorCaching:
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
scope_identifier="scope-123", scopes=[]
)
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
messages = [Message(role="user", contents=["Test"])]
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
await asyncio.gather(*list(processor._background_tasks))
mock_client.get_protection_scopes.assert_called_once()
@@ -670,7 +787,7 @@ class TestScopedContentProcessorCaching:
async def test_payment_required_exception_cached_at_tenant_level(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that 402 payment required exceptions are cached at tenant level."""
"""Test that background scope 402 returns once, then throws from the tenant-level cache."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
@@ -678,13 +795,12 @@ class TestScopedContentProcessorCaching:
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
messages = [Message(role="user", contents=["Test"])]
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
)
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
await asyncio.gather(*list(processor._background_tasks))
mock_client.get_protection_scopes.assert_called_once()
+12 -2
View File
@@ -45,13 +45,23 @@ python samples/02-agents/harness/harness_research.py
### Minimal Setup
`create_harness_agent` requires only a chat client and token budget parameters:
`create_harness_agent` requires only a chat client:
```python
from agent_framework import create_harness_agent
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
agent = create_harness_agent(
client=FoundryChatClient(credential=AzureCliCredential()),
)
```
### With Compaction
Provide token budget parameters to enable automatic context-window compaction:
```python
agent = create_harness_agent(
client=FoundryChatClient(credential=AzureCliCredential()),
max_context_window_tokens=128_000,
@@ -59,7 +69,7 @@ agent = create_harness_agent(
)
```
### Customization
### Further Customization
Disable or customize any feature:
@@ -313,9 +313,7 @@ class HarnessAgentRunner:
"""
actions: list[FollowUpAction] = []
for observer in self._observers:
observer_actions = await observer.on_stream_complete(
self._ux, self._agent, session
)
observer_actions = await observer.on_stream_complete(self._ux, self._agent, session)
if observer_actions:
actions.extend(observer_actions)
return actions
@@ -182,18 +182,12 @@ class HarnessApp(App[None]):
if command_handlers is None:
from .commands import build_default_command_handlers
self._command_handlers = build_default_command_handlers(
agent, mode_colors=mode_colors
)
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
else:
self._command_handlers = command_handlers
# Compute help text from command handlers
help_parts = [
h.get_help_text()
for h in self._command_handlers
if h.get_help_text() is not None
]
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
help_text = ", ".join(help_parts) if help_parts else None
# State and driver
@@ -45,9 +45,7 @@ class TodoCommandHandler(CommandHandler):
ux.append_info_line("TodoProvider is not available.")
return True
todos = await self._todo_provider.store.load_items(
session, source_id=self._todo_provider.source_id
)
todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id)
if not todos:
ux.append_info_line("No todos yet.")
@@ -72,7 +72,7 @@ class HarnessScrollPanel(RichLog):
# Truncate lines back to where streaming started
if len(self.lines) > self._streaming_line_start:
del self.lines[self._streaming_line_start:]
del self.lines[self._streaming_line_start :]
from textual.geometry import Size
self.virtual_size = Size(self._widest_line_width, len(self.lines))
@@ -41,8 +41,7 @@ class PlanningQuestion(BaseModel):
choices: list[str] | None = Field(
default=None,
description=(
"For clarifications, this has a list of options that the user can "
"choose from. null for approvals."
"For clarifications, this has a list of options that the user can choose from. null for approvals."
),
)
+1
View File
@@ -14,6 +14,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument |
| **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication |
| **Long-Running Task** | [`mcp_long_running_task.py`](mcp_long_running_task.py) | Demonstrates transparent SEP-2663 long-running task handling for MCP tools that advertise `taskSupport=required`. Self-spawns a stdio MCP child server |
| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default |
## Prerequisites
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
from mcp import types
# Load environment variables from .env file
load_dotenv()
"""
MCP Sampling Approval Example
MCP servers can send the client a ``sampling/createMessage`` request, asking the
client to run an LLM completion on the server's behalf. Because remote MCP
servers are untrusted third parties, forwarding these server-controlled prompts
to your chat client without review is a confused-deputy risk: a malicious server
could exfiltrate context, force tool calls, or burn through your token budget.
For that reason Agent Framework **denies MCP sampling by default**. To allow it,
pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the
raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False``
to deny. It may be synchronous or asynchronous, so you can implement a
human-in-the-loop prompt, a policy check, or an audit log.
Two further guardrails apply to approved requests:
- ``sampling_max_tokens`` caps the server-requested ``maxTokens``.
- ``sampling_max_requests`` limits how many sampling requests a single session
may make.
To restore the legacy "always approve" behavior (only do this for servers you
trust), pass ``sampling_approval_callback=lambda params: True``.
"""
async def approve_sampling(params: types.CreateMessageRequestParams) -> bool:
"""Human-in-the-loop approval gate for server-initiated sampling.
Shows the server-supplied system prompt and messages, then asks the user to
approve or deny. Returning ``False`` rejects the request.
"""
print("\n--- MCP server requested a sampling/createMessage ---")
if params.systemPrompt:
print(f"System prompt: {params.systemPrompt}")
for message in params.messages:
text = getattr(message.content, "text", message.content)
print(f"{message.role}: {text}")
answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ")
return answer.strip().lower() in {"y", "yes"}
async def main() -> None:
"""Run an agent against an MCP server with a sampling approval gate."""
async with Agent(
client=OpenAIChatClient(),
name="Agent",
instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.",
tools=MCPStreamableHTTPTool(
name="MCP tool",
description="MCP tool description.",
url="<your mcp server url>",
# Passing ``client`` enables sampling; the approval callback gates it.
client=OpenAIChatClient(),
sampling_approval_callback=approve_sampling,
sampling_max_tokens=2048,
sampling_max_requests=5,
),
) as agent:
query = "Use your MCP tool to help answer this question."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
if __name__ == "__main__":
asyncio.run(main())
@@ -3,7 +3,7 @@
This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach.
**What this sample demonstrates:**
1. Configure an Azure OpenAI chat client
1. Configure a Foundry chat client
2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`)
3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`)
4. Implement a custom cache provider for advanced caching scenarios
@@ -17,8 +17,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
| Variable | Required | Purpose |
|----------|----------|---------|
| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://<name>.openai.azure.com) |
| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) |
| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Azure AI Foundry project endpoint, for example `https://<resource>.services.ai.azure.com/api/projects/<project>` |
| `FOUNDRY_MODEL` | Optional | Model deployment name (defaults to `gpt-4o-mini`) |
| `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication |
| `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth |
| `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication |
@@ -31,7 +31,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
Opens a browser on first run to sign in.
```powershell
$env:AZURE_OPENAI_ENDPOINT = "https://your-openai-instance.openai.azure.com"
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<resource>.services.ai.azure.com/api/projects/<project>"
$env:FOUNDRY_MODEL = "gpt-4o-mini"
$env:PURVIEW_CLIENT_APP_ID = "00000000-0000-0000-0000-000000000000"
```
@@ -64,22 +65,27 @@ If interactive auth is used, a browser window will appear the first time.
## 4. How It Works
The sample demonstrates three different scenarios:
The sample demonstrates four integration scenarios. Each scenario runs the same three-message sequence via `run_policy_flow(...)`:
1. **good (cold cache)** - a benign prompt that exercises the cold-cache parallel ProtectionScopes warmup + foreground ProcessContent path.
2. **expected block** - a sensitive prompt containing the Visa test credit card number `4111 1111 1111 1111`. If the tenant has a DLP policy for `Microsoft 365 Copilot and AI apps` targeting the Credit Card sensitive info type with a Block action, this prompt returns the configured `blocked_prompt_message` (default: `Prompt blocked by policy`). If no DLP policy applies, the prompt is allowed (the LLM may still decline on its own, but that is a model-level response, not a Purview block).
3. **good (warm cache)** - a second benign prompt that exercises the warm-cache path. The custom cache provider scenario prints `Cache HIT` for the same protection-scopes key, confirming the cache and middleware state survive a prior block.
### A. Agent Middleware (`run_with_agent_middleware`)
1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment)
1. Builds a Foundry chat client (using the environment project endpoint / deployment)
2. Chooses credential mode (certificate vs interactive)
3. Creates `PurviewPolicyMiddleware` with `PurviewSettings`
4. Injects middleware into the agent at construction
5. Sends two user messages sequentially
6. Prints results (or policy block messages)
5. Runs the three-message `good -> block -> good` orchestration
6. Prints `ALLOWED` or `BLOCKED` per message, plus the model response
7. Uses default caching automatically
### B. Chat Client Middleware (`run_with_chat_middleware`)
1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly
2. Policy evaluation happens at the chat client level rather than agent level
3. Demonstrates an alternative integration point for Purview policies
4. Uses default caching automatically
4. Runs the same `good -> block -> good` orchestration
5. Uses default caching automatically
### C. Custom Cache Provider (`run_with_custom_cache_provider`)
1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`)
@@ -88,9 +94,27 @@ The sample demonstrates three different scenarios:
- `async def get(self, key: str) -> Any | None`
- `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None`
- `async def remove(self, key: str) -> None`
4. Runs the `good -> block -> good` orchestration and prints `Cache MISS`/`Cache HIT` traces alongside policy outcomes, showing the cold-cache warmup populating the cache and warm-cache requests skipping ProtectionScopes.
### D. Default Cache (`run_with_default_cache`)
1. Same as the agent middleware path but with explicit cache TTL and size limits in `PurviewSettings`
2. Uses the default in-memory `CacheProvider`
3. Runs the `good -> block -> good` orchestration
**Policy Behavior:**
Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`.
Prompt blocks substitute the configured `blocked_prompt_message` (default `Prompt blocked by policy`) and terminate the agent run early. Response blocks substitute `blocked_response_message`. The LLM is never called for a blocked prompt.
**Seeing a real `BLOCKED` outcome:**
The middle prompt only returns `BLOCKED` if the tenant actually has a Purview DLP policy that matches the request. Specifically, all of the following must be true:
1. The Entra app id used by `PURVIEW_CLIENT_APP_ID` (the same id Agent Framework sends as `policyLocationApplication.value`) is registered as an integrated AI app in Purview (Settings -> AI app and agent locations).
2. A DLP policy in the tenant targets the location `Microsoft 365 Copilot and AI apps`, scoped to that app id (or `All apps`).
3. The policy has a rule with the condition `Content contains -> Sensitive info types -> Credit Card Number` and an action of `Restrict access to Microsoft 365 Copilot and AI apps -> Block`.
4. The policy is `On` (not `Test mode without notifications`).
5. The signed-in user is in the policy's user scope.
6. Required Graph delegated permissions are admin-consented: `ProtectionScopes.Compute.All`, `Content.Process.All`, `ContentActivity.Write`.
If any of those are missing, the credit card prompt is allowed at the Purview layer. The model itself may still decline on its own; that response is a model-level refusal, not a Purview block. The cold/warm cache orchestration is still demonstrated either way - the `Cache MISS -> Cache HIT` trace from the custom cache scenario does not depend on a block firing.
---
@@ -11,8 +11,8 @@ Shows:
Note: Caching is automatic and enabled by default.
Environment variables:
- AZURE_OPENAI_ENDPOINT (required)
- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini)
- FOUNDRY_PROJECT_ENDPOINT (required) - Azure AI Foundry project endpoint URL
- FOUNDRY_MODEL (optional, defaults to gpt-4o-mini)
- PURVIEW_CLIENT_APP_ID (required)
- PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth)
- PURVIEW_TENANT_ID (required if certificate auth)
@@ -45,6 +45,37 @@ load_dotenv()
JOKER_NAME = "Joker"
JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise."
# Sequential prompts to demonstrate good -> block -> good orchestration.
# The sensitive prompt contains a Visa test credit card number that matches Purview's
# built-in Credit Card sensitive information type. If the tenant has a DLP policy that
# blocks credit card content for Microsoft 365 Copilot and AI apps, the second message
# will be blocked and the third will verify that subsequent calls still flow normally
# after a block.
GOOD_PROMPT_PRIMARY = "Tell me a joke about a pirate."
SENSITIVE_PROMPT = "My corporate credit card is 4111 1111 1111 1111. Please confirm receipt."
GOOD_PROMPT_FOLLOWUP = "Another light joke please."
async def run_policy_flow(
label: str,
agent: Agent,
user_id: str | None,
blocked_text: str,
) -> None:
"""Run a good -> block candidate -> good sequence and report each outcome."""
blocked_marker = blocked_text.lower()
prompts = [
("good (cold cache)", GOOD_PROMPT_PRIMARY),
("expected block", SENSITIVE_PROMPT),
("good (warm cache)", GOOD_PROMPT_FOLLOWUP),
]
for tag, text in prompts:
response: AgentResponse = await agent.run(
Message("user", [text], additional_properties={"user_id": user_id})
)
outcome = "BLOCKED" if blocked_marker in str(response).lower() else "ALLOWED"
print(f"[{label}] {tag}: {outcome}\n{response}\n")
# Custom Cache Provider Implementation
class SimpleDictCacheProvider:
@@ -138,21 +169,17 @@ def build_credential() -> Any:
async def run_with_agent_middleware() -> None:
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not endpoint:
print("Skipping run: AZURE_OPENAI_ENDPOINT not set")
print("Skipping run: FOUNDRY_PROJECT_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
purview_agent_middleware = PurviewPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App",
),
)
settings = PurviewSettings(app_name="Agent Framework Sample App")
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
agent = Agent(
client=client,
@@ -162,39 +189,26 @@ async def run_with_agent_middleware() -> None:
)
print("-- Agent MiddlewareTypes Path --")
first: AgentResponse = await agent.run(
Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id})
)
print("First response (agent middleware):\n", first)
second: AgentResponse = await agent.run(
Message(
role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id}
)
)
print("Second response (agent middleware):\n", second)
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
await run_policy_flow("agent middleware", agent, user_id, blocked_text)
async def run_with_chat_middleware() -> None:
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not endpoint:
print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set")
print("Skipping chat middleware run: FOUNDRY_PROJECT_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini")
deployment = os.environ.get("FOUNDRY_MODEL", default="gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
settings = PurviewSettings(app_name="Agent Framework Sample App (Chat)")
client = FoundryChatClient(
model=deployment,
endpoint=endpoint,
project_endpoint=endpoint,
credential=AzureCliCredential(),
middleware=[
PurviewChatPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App (Chat)",
),
)
PurviewChatPolicyMiddleware(build_credential(), settings)
],
)
@@ -205,43 +219,27 @@ async def run_with_chat_middleware() -> None:
)
print("-- Chat MiddlewareTypes Path --")
first: AgentResponse = await agent.run(
Message(
role="user",
contents=["Give me a short clean joke."],
additional_properties={"user_id": user_id},
)
)
print("First response (chat middleware):\n", first)
second: AgentResponse = await agent.run(
Message(
role="user",
contents=["One more please."],
additional_properties={"user_id": user_id},
)
)
print("Second response (chat middleware):\n", second)
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
await run_policy_flow("chat middleware", agent, user_id, blocked_text)
async def run_with_custom_cache_provider() -> None:
"""Demonstrate implementing and using a custom cache provider."""
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not endpoint:
print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set")
print("Skipping custom cache provider run: FOUNDRY_PROJECT_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
custom_cache = SimpleDictCacheProvider()
settings = PurviewSettings(app_name="Agent Framework Sample App (Custom Provider)")
purview_agent_middleware = PurviewPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App (Custom Provider)",
),
settings,
cache_provider=custom_cache,
)
@@ -254,38 +252,28 @@ async def run_with_custom_cache_provider() -> None:
print("-- Custom Cache Provider Path --")
print("Using SimpleDictCacheProvider")
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
await run_policy_flow("custom cache", agent, user_id, blocked_text)
first: AgentResponse = await agent.run(
Message(
role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id}
)
)
print("First response (custom provider):\n", first)
second: AgentResponse = await agent.run(
Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id})
)
print("Second response (custom provider):\n", second)
async def run_with_default_cache() -> None:
"""Demonstrate using the default built-in cache."""
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not endpoint:
print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set")
print("Skipping default cache run: FOUNDRY_PROJECT_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
# No cache_provider specified - uses default InMemoryCacheProvider
purview_agent_middleware = PurviewPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App (Default Cache)",
cache_ttl_seconds=3600,
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
),
settings = PurviewSettings(
app_name="Agent Framework Sample App (Default Cache)",
cache_ttl_seconds=3600,
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
)
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
agent = Agent(
client=client,
@@ -296,16 +284,8 @@ async def run_with_custom_cache_provider() -> None:
print("-- Default Cache Path --")
print("Using default InMemoryCacheProvider with settings-based configuration")
first: AgentResponse = await agent.run(
Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id})
)
print("First response (default cache):\n", first)
second: AgentResponse = await agent.run(
Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id})
)
print("Second response (default cache):\n", second)
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
await run_policy_flow("default cache", agent, user_id, blocked_text)
async def main() -> None:
@@ -326,6 +306,11 @@ async def main() -> None:
except Exception as ex: # pragma: no cover - demo resilience
print(f"Custom cache provider path failed: {ex}")
try:
await run_with_default_cache()
except Exception as ex: # pragma: no cover - demo resilience
print(f"Default cache path failed: {ex}")
if __name__ == "__main__":
asyncio.run(main())