Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 3f8c498d03 build(deps): bump actions/cache from 4.3.0 to 5.0.5
Bumps [actions/cache](https://github.com/actions/cache) from 4.3.0 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4.3.0...27d5ce7f107fe9357f9df03efb73ab90386fccae)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 22:32:09 +00:00
Roger BarretoandGitHub 01a3c5be8a ci: pin third-party GitHub Actions to commit SHAs (#5972)
Replaces every floating tag in our workflow and composite action files
with an immutable 40-character commit SHA, keeping the original `# vX`
comment so Dependabot can still propose version bumps. 186 occurrences
across 25 workflows and 2 composite actions.

Also widens the github-actions Dependabot entry to use the plural
`directories` key with `/.github/actions/*` so composite actions under
`.github/actions/<name>/action.yml` are kept up to date. Previously
Dependabot only scanned `.github/workflows` and the repo-root
`action.yml`, leaving our `python-setup` and `sample-validation-setup`
composite actions unmaintained.
2026-05-20 22:10:32 +00:00
d74d26c917 Python: Show more authentication methods in Foundry Toolbox MCP (#5719)
* Show more authentication methods in Foundry Toolbox MCP

* Remove hardcoded toolbox version num

* Add Foundry MCP OAuth consent handling

* Use message instead of the dedicated item type

* Go back to using OAuthConsentRequestOutputItem

* WIP: sample testing

* Update error code

* Address review on Foundry Toolbox MCP samples

Reviewed feedback addressed:

- Drop the branch-pinned `git+https://...@feature/...` entries from
  `04_foundry_toolbox/requirements.txt`; restore the simple comment + `mcp`
  runtime dep. The git pins were only useful while iterating on the PR and
  shouldn't ship. (eavanvalkenburg)

- Fix the `/toolsets/` typo in both `04_foundry_toolbox/README.md` and
  `06_files/README.md`. Verified empirically against the
  research_toolbox in the test workspace: the toolbox MCP gateway lives at
  `/toolboxes/{name}/mcp?api-version=v1` and requires the
  `Foundry-Features: Toolboxes=V1Preview` header. `/toolsets/{name}/mcp`
  returns 403 with `preview_feature_required: Toolsets=V1Preview` (a
  different opt-in feature).

- Wrap `httpx.AsyncClient(...)` in `async with ... as http_client:` in both
  samples so the connection pool is cleaned up. (Copilot reviewer)

- Make the `TOOLBOX_NAME` env var consistent in both samples. Previously the
  tool name silently fell back to `"toolbox"` when `TOOLBOX_NAME` was unset,
  but `resolve_toolbox_endpoint()` still required `TOOLBOX_NAME` and would
  raise `KeyError`. The samples now resolve the endpoint once and derive the
  tool name from the resolved URL when `TOOLBOX_NAME` isn't set, so the
  local tool name always matches the upstream toolbox identity regardless
  of which env var the user set. (Copilot reviewer)

- Rename `_responses.is_consent_error` to `consent_url_from_error`: the
  helper returns `str | None` (the consent URL), not a bool, so the new
  name matches behavior. Update the test class accordingly. (eavanvalkenburg)

- Tighten `_handle_inner_agent`'s lazy-entry catch from `Exception` to
  `AgentFrameworkException`, the type the MCP layer actually wraps consent
  errors in via `MCPStreamableHTTPTool.__aenter__` →
  `ToolExecutionException(inner_exception=mcp_error)`. Network failures,
  cancellations, and other non-framework exceptions now propagate normally
  instead of being briefly caught and re-raised. The test helper
  `_make_consent_error` is updated to use `ToolExecutionException` so it
  matches the real-world wrapping. (eavanvalkenburg)

- Clarify the `github_pat` description in `agent.manifest.yaml` to note
  it's only needed when the PAT-based connection (`github-mcp-pat-conn`)
  is chosen; users selecting the OAuth2 connection (`github-mcp-oauth-conn`)
  can leave it empty. (Copilot reviewer)

Validation: ran both samples end-to-end against a real Foundry toolbox
(`research_toolbox`) -- the samples connect successfully and the agent
lists the toolbox's MCP tools (`api_specs___fetch_azure_rest_api_docs`,
etc.). `uv run poe test -P foundry_hosting` passes (119 tests), pyright +
mypy clean.

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

* docs: fix broken Foundry samples link in 04_foundry_toolbox README

The previous URL pointed to an old location of the toolbox supported-scenarios
doc; the doc moved to /samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md
and the old /samples/python/toolbox/azd path now 404s.

Caught by the markdown-link-check CI step.

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

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 12:00:38 +00:00
72a6157c6a [BREAKING] Python: Enable instrumentation by default (#5865)
* Enable instrumentation by default

* Update samples

* Optimization when span is not recording

* Address Copilot comments

* Revert uv.lock

* Add warning

* Formatting

* Fix mypy

* Add disable_instrumentation() with sticky user-intent semantics

Add a public disable_instrumentation() entry point so users can explicitly opt
out of Agent Framework telemetry, with a sticky-disable flag that makes the
user's intent "leading" — no framework code path (foundry's
configure_azure_monitor, configure_otel_providers, enable_instrumentation,
enable_sensitive_telemetry, or direct OBSERVABILITY_SETTINGS.enable_*
writes) can re-enable instrumentation until the user explicitly clears the
disable with enable_instrumentation(force=True) /
enable_sensitive_telemetry(force=True).

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

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

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

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

* docs: document disable_instrumentation() and force=True paths

Add a "Disabling instrumentation" section to the observability sample README
that walks through:

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

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

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

* docs: soften disable_instrumentation() overclaim about telemetry guarantees

Replace 'no telemetry will be emitted no matter what' (which is too strong,
since callers can still pass force=True or mutate private attributes) with
language framing the disable as a user-intent contract that library and
framework code is expected to honor: the framework actively short-circuits
the public enable paths, force=True and private-attribute writes are
acknowledged as out-of-contract escape hatches that integrations should
not use on the user's behalf.

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

* docs: correct observability Dependencies section

- opentelemetry-sdk is no longer a hard dependency; it is lazily imported by
  create_resource(), create_metric_views(), and configure_otel_providers()
  with a clear ImportError when missing. Day-to-day instrumentation works
  with opentelemetry-api alone provided some other component configures the
  global OpenTelemetry providers (Azure Monitor, an APM agent, application
  bootstrap, etc.).
- opentelemetry-semantic-conventions-ai is no longer used anywhere in the
  source; remove it from the listed dependencies.

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

* docs: replace stale observability migration guide with current PR's only relevant migration

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

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

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 11:52:08 +00:00
BaidarandGitHub 0ba552b84c Python: Skip MCP prompt loading when unsupported (#5370)
* Python: Skip MCP prompt loading when unsupported

* Fix MCP pagination pyright checks

* Simplify MCP support flag checks
2026-05-20 11:50:26 +00:00
dd1e615dad .NET: Add A2AAgentOptions and align A2AAgent constructors with ChatClientAgent pattern (#5954)
* .NET: Add A2AAgentOptions and align A2AAgent constructors with ChatClientAgent pattern

Adds a new A2AAgentOptions class (Id, Name, Description, Clone) and an options-based constructor on A2AAgent, mirroring ChatClientAgent/ChatClientAgentOptions. The existing parameter-based constructor is preserved for backward compatibility and now delegates to the options-based one.

Extension methods are extended with options-based overloads:

- A2AClientExtensions.AsAIAgent(IA2AClient, A2AAgentOptions, ...)

- A2AAgentCardExtensions.AsAIAgent(AgentCard, A2AAgentOptions, ...)

- A2ACardResolverExtensions.GetAIAgentAsync(A2ACardResolver, A2AAgentOptions, ...)

For card-based creation, user-supplied options override values from the agent card; Name and Description fall back to card values when not set.

Options are cloned when stored on the agent to prevent post-construction mutation, matching the ChatClientAgent pattern.

Resolves #5870.

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

* Address PR review comments

- Add Throw.IfNull(client) in A2AClientExtensions.AsAIAgent

- Add Throw.IfNull(card) in A2AAgentCardExtensions.AsAIAgent

- Clarify httpClient docs in A2ACardResolverExtensions.GetAIAgentAsync: it applies to the created A2A client, not to card discovery

- Rename test methods from GetAIAgent_* to AsAIAgent_* to match the API under test

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 10:05:24 +00:00
Evan MattsonandGitHub f390595188 Bump to 1.0.0rc2 for unique version (#5965) 2026-05-20 10:01:44 +09:00
4609535e22 Python: feat: add agent-framework-monty (Monty-backed CodeAct provider) (#5915)
* Python: feat: add agent-framework-monty (Monty-backed CodeAct)

New alpha package that wraps pydantic-monty (a Rust-based Python
interpreter) behind the same CodeAct API surface as
agent-framework-hyperlight, so users can swap providers with minimal
code change.

Public API (agent_framework_monty):
- MontyCodeActProvider — ContextProvider that injects a run-scoped
  execute_code tool plus dynamic CodeAct instructions.
- MontyExecuteCodeTool — standalone FunctionTool for mixed-tool agents
  or manual static wiring.
- FileMount / FileMountInput / MountMode — public types mirroring the
  Hyperlight names, with Monty's mode (read-only/read-write/overlay)
  and write_bytes_limit on FileMount.

Constructor kwargs (both classes) mirror Hyperlight where possible:
tools, approval_mode, workspace_root, file_mounts; plus a Monty-only
resource_limits forwarding ResourceLimits to Monty.start().

Filesystem flow:
- workspace_root auto-mounts at /input (read-write), matching Hyperlight.
- file_mounts accepts string shorthand, (host, mount) tuple, or
  FileMount with mode + write cap.
- Files written under read-write mounts are scanned post-execution and
  returned as Content.from_data items (mirrors Hyperlight /output).
- overlay mounts buffer writes in-memory; read-only mounts reject writes.

Internals:
- _monty_bridge.InlineCodeBridge ports the inline (non-durable) bridge
  from anthonychu/maf-codeact-monty-python; handles FunctionSnapshot /
  FutureSnapshot pause/resume, dispatches direct typed calls + the
  call_tool fallback, forwards mount/limits to Monty.start(...).
- generate_type_stubs emits per-tool stubs so Monty's `ty` type-checker
  rejects bad calls before any host tool runs.

Alpha-policy compliance (per python-package-management skill):
- Added agent-framework-monty = { workspace = true } to root
  pyproject.toml.
- Added row to python/PACKAGE_STATUS.md.
- Added monty entry under Experimental in python/AGENTS.md.
- NOT added to core[all]; NO agent_framework.monty lazy shim (deferred
  to beta promotion).

Samples (three sets, import from agent_framework_monty directly):
- samples/02-agents/context_providers/code_act/monty_code_act.py
  (provider pattern) + updated local README.
- samples/02-agents/tools/monty_code_interpreter/ (standalone +
  manual-wiring + README).
- samples/04-hosting/foundry-hosted-agents/responses/11_monty_codeact/
  (full hosted-agent layout with uv-based pyproject.toml + Dockerfile,
  Azure Monitor wiring via APPLICATIONINSIGHTS_CONNECTION_STRING +
  enable_instrumentation, ENABLE_INSTRUMENTATION and
  ENABLE_SENSITIVE_DATA env vars). The alpha wheel is vendored into
  ./wheels/ (gitignored) via vendor-wheel.sh; new row added to the
  parent Responses-API README.

Tests:
- 28 hermetic unit tests (stubbed pydantic_monty).
- 18 integration tests marked @pytest.mark.integration, auto-skipped
  when pydantic_monty is unimportable; exercise the real Monty
  runtime: print round-trip, last-expression value, direct typed
  tool dispatch, call_tool fallback, async tool, asyncio.gather
  parallelism, ty type-check rejection, OS blocked by default,
  workspace_root read+write capture, read-only / overlay mount
  semantics, resource_limits.max_duration_secs abort, approval
  gating end-to-end, full Agent run with a scripted chat client.

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

* Python: fix: monty FileMount test compares against the normalized POSIX path

The shorthand string mount goes through _normalize_mount_path, which
rewrites Windows drive letters like 'C:\\Users\\...' into
'/C:/Users/...' (POSIX-style). The Windows CI runners surfaced this
because tmp_path resolves to a backslashed Windows path; the test was
comparing against the raw str(host_a) instead of the normalized form.

Compare against _normalize_mount_path(str(host_a)) so the assertion is
platform-independent.

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

* Python: fix: address PR #5915 review feedback

- _execute_code_tool docstring: clarify that the Monty backend supports
  scoped filesystem access via workspace_root / file_mounts (blocked by
  default).
- _to_monty_mount: import pydantic_monty lazily through load_monty so
  missing-dependency errors surface as the same actionable RuntimeError
  the rest of the package raises (not a bare ImportError at module load).
  Renamed _load_monty -> load_monty for the same reason.
- _python_type_repr: emit None for type(None) instead of Any, and
  normalize both typing.Union[...] and PEP-604 X | Y to PEP-604 syntax
  so Optional[X] / Union[..., None] / -> None signatures round-trip
  correctly through ty validation. Added a regression test.
- _PrintCollector: track a running character count instead of
  recomputing sum(len(c) for c in self.chunks) per callback. Eliminates
  the O(n^2) cost on print-heavy code.
- Instructions: mention that the value of the final expression is also
  returned alongside captured stdout (matches actual behavior).
- 11_monty_codeact Dockerfile: pin ghcr.io/astral-sh/uv to 0.11.6
  instead of :latest for reproducible builds.
- 11_monty_codeact README: replace the bare "see parent README" pointer
  with sample-specific steps (./vendor-wheel.sh + uv sync + uv run),
  since the sample uses pyproject.toml + a vendored wheel rather than
  requirements.txt.

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

* Python: sample: 11_monty_codeact installs agent-framework-monty from PyPI

Drop the vendored-wheel scaffolding now that agent-framework-monty is on
PyPI as an alpha (1.0.0a*) release:

- pyproject.toml: remove [tool.uv.sources] override; keep [tool.uv]
  prerelease = "allow" so uv pulls the alpha automatically.
- Dockerfile: drop the COPY wheels/ step.
- README: drop the ./vendor-wheel.sh setup step and the
  not-yet-on-PyPI warning.
- Delete vendor-wheel.sh and the gitignored wheels/ directory.

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

* Python: fix(monty): harden post-execution file capture against symlink escape

Same class of issue as the MSRC-reported Hyperlight finding: the
post-execution capture walked workspace_root with Path.rglob() +
is_file() + read_bytes() - all of which follow symlinks. An attacker
who controls the workspace (cloned repo, extracted archive, shared
workspace) could pre-place `workspace/leak.txt -> /etc/passwd` or
`workspace/outside_dir -> /etc/` and have host files surface as
captured Content items.

Monty's mount layer already rejects symlink reads from inside the
sandbox across all three modes (verified empirically), so the runtime
path was safe. This commit closes the post-execution scan path.

Changes:
- New `_iter_real_files(root)` walker that uses iterdir() +
  is_symlink() to skip symlinks at every directory level and yields
  only real files. Replaces the previous `host_root.rglob("*")` calls
  in both `_snapshot_writable_mounts` and `_capture_written_files`.
- Use `Path.lstat()` instead of `Path.stat()` so size/mtime can never
  be taken from a symlink target.
- Three new integration tests reproducing the MSRC attack shape
  against the workspace_root flow: symlink-to-file outside workspace,
  symlink-to-directory outside workspace, and a guard ensuring
  legitimate sandbox writes are still captured when symlinks are
  present.

Per user request, hyperlight is untouched in this commit (separate fix).

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

* Python: fix(monty): skip symlink regression tests when unsupported

Apply the same Windows-CI safety guard as the hyperlight fix in PR #5919:
the three symlink integration tests create symlinks via Path.symlink_to(),
which fails with OSError / NotImplementedError on unprivileged Windows
runners. Add a local _symlinks_supported helper (mirroring the one in
packages/core/tests/core/test_skills.py) and pytest.skip when symlinks
aren't available, so the tests no longer fail for environment reasons.

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

* Python: fix(monty): address PR #5915 follow-up review feedback

- _invoke_tool: drop the inspect.iscoroutinefunction(...) branch and
  always `await self.tool_map[name](**kwargs)`. Every entry in
  tool_map is `partial(FunctionTool.invoke, skip_parsing=True)` and
  FunctionTool.invoke is `async def`, so the branching was dead code -
  and on Python versions affected by cpython#98590,
  iscoroutinefunction(partial(bound_async_method, ...)) returns False,
  causing the bridge to take the asyncio.to_thread path, return an
  unawaited coroutine, and surface it as a JSON-serialization failure
  for every tool call. Added a regression test
  test_invoke_tool_awaits_partial_wrapped_async_method.

- generate_type_stubs: skip tools whose name is not a valid Python
  identifier or is a Python keyword. FunctionTool.name has no upstream
  validation, so a name like "weird-name" produced a syntax error in
  the stubs and a name like "broken\n    pass\nasync def injected"
  would inject arbitrary stub source. Non-identifier names stay
  reachable via `call_tool("weird-name", ...)` at runtime; they just
  don't get type-checked stubs. Added regression test
  test_generate_type_stubs_skips_non_identifier_tool_names.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 00:35:23 +00:00
96 changed files with 6100 additions and 730 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version-file: "python/pyproject.toml"
enable-cache: true
@@ -24,7 +24,7 @@ runs:
using: "composite"
steps:
- name: Set up Node.js environment
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
@@ -37,7 +37,7 @@ runs:
run: copilot --version && copilot -p "What can you do in one sentence?"
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
+9 -3
View File
@@ -44,9 +44,15 @@ updates:
# Maintain dependencies for github-actions
- package-ecosystem: "github-actions"
# Workflow files stored in the
# default location of `.github/workflows`
directory: "/"
# Cover both the standard workflow location and our composite actions.
# With `directory: "/"` Dependabot only scans `.github/workflows/*.{yml,yaml}`
# plus a root-level `action.yml/action.yaml`. It does NOT recurse into
# `.github/actions/*/action.yml`, so the glob below is required to keep the
# composite actions in `.github/actions/<name>/` up to date as well.
# Ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
directories:
- "/"
- "/.github/actions/*"
schedule:
interval: "weekly"
day: "sunday"
+4 -4
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
category: "/language:${{matrix.language}}"
+5 -5
View File
@@ -66,7 +66,7 @@ jobs:
- name: Check PR author team membership
id: check
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
@@ -116,7 +116,7 @@ jobs:
steps:
# Safe checkout: base repo only, not the untrusted PR head.
- name: Checkout target repo base
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 0
@@ -125,7 +125,7 @@ jobs:
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
- name: Checkout DevFlow
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -135,12 +135,12 @@ jobs:
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.11.x"
enable-cache: true
+24 -24
View File
@@ -41,8 +41,8 @@ jobs:
functionsChanged: ${{ steps.filter.outputs.functions }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -111,7 +111,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -122,7 +122,7 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
@@ -181,7 +181,7 @@ jobs:
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -202,7 +202,7 @@ jobs:
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -271,7 +271,7 @@ jobs:
- name: Azure CLI Login
if: github.event_name != 'pull_request' && matrix.integration-tests
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -318,7 +318,7 @@ jobs:
# Generate test reports and check coverage
- name: Generate test reports
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
with:
reports: "./TestResults/Coverage/**/*.cobertura.xml"
targetdir: "./TestResults/Reports"
@@ -326,7 +326,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
@@ -338,7 +338,7 @@ jobs:
- name: Upload integration test results
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
path: IntegrationTestResults/**/*.junit
@@ -356,7 +356,7 @@ jobs:
env:
configuration: Release
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -366,7 +366,7 @@ jobs:
python
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -381,7 +381,7 @@ jobs:
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -442,7 +442,7 @@ jobs:
runs-on: ubuntu-latest
environment: integration
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -453,7 +453,7 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -465,7 +465,7 @@ jobs:
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -522,7 +522,7 @@ jobs:
- name: Upload functions test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dotnet-test-results-functions-net10.0-ubuntu-latest
path: IntegrationTestResults/**/*.junit
@@ -560,14 +560,14 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Cancelled!')
@@ -585,7 +585,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -597,12 +597,12 @@ jobs:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
@@ -619,13 +619,13 @@ jobs:
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
- name: Upload trend report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dotnet-integration-test-report
path: |
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
- name: Get changed files
id: changed-files
if: github.event_name == 'pull_request'
uses: jitterbit/get-changed-files@v1
uses: jitterbit/get-changed-files@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
continue-on-error: true
- name: No C# files changed
@@ -29,7 +29,7 @@ jobs:
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -63,7 +63,7 @@ jobs:
done
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+4 -4
View File
@@ -41,7 +41,7 @@ jobs:
environment: 'integration'
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
@@ -52,13 +52,13 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -123,7 +123,7 @@ jobs:
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: verify-samples-results
path: |
+7 -7
View File
@@ -53,7 +53,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
@@ -61,7 +61,7 @@ jobs:
- name: Check issue author team membership
id: check
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
@@ -93,7 +93,7 @@ jobs:
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
@@ -101,7 +101,7 @@ jobs:
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -111,12 +111,12 @@ jobs:
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.11.x"
enable-cache: true
@@ -126,7 +126,7 @@ jobs:
run: uv sync --frozen
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
pull-requests: write
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
with:
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
name: "Issue/PR: update title"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Checks the status of hyperlinks in all files
- name: Run linkspector
uses: umbrelladocs/action-linkspector@v1
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
with:
reporter: local
filter_mode: nofilter
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Wait for required checks
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TIMEOUT_SECONDS: "3600"
INTERVAL_SECONDS: "30"
+6 -6
View File
@@ -27,7 +27,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -38,11 +38,11 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@v5
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
- uses: j178/prek-action@v1
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
name: Run Pre-commit Hooks (excluding poe-check)
env:
SKIP: poe-check
@@ -64,7 +64,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -93,7 +93,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -124,7 +124,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -22,7 +22,7 @@ jobs:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
@@ -44,7 +44,7 @@ jobs:
- name: Upload dependency range report
# Always publish the report so failures are inspectable even when validation fails.
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dependency-range-results
path: python/scripts/dependencies/dependency-range-results.json
@@ -53,7 +53,7 @@ jobs:
- name: Create issues for failed dependency candidates
# Always process the report so failed candidates create actionable tracking issues.
if: always()
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require("fs")
@@ -18,7 +18,7 @@ jobs:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version-file: "python/pyproject.toml"
enable-cache: true
+27 -27
View File
@@ -36,7 +36,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -69,7 +69,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -90,7 +90,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -112,7 +112,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -141,7 +141,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -163,7 +163,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -231,7 +231,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -283,7 +283,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -294,7 +294,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -315,7 +315,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -352,7 +352,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -369,7 +369,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -388,7 +388,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -399,7 +399,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -416,7 +416,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -443,7 +443,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -468,7 +468,7 @@ jobs:
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -496,7 +496,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -506,12 +506,12 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
@@ -528,13 +528,13 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: integration-test-report
path: |
@@ -558,12 +558,12 @@ jobs:
steps:
- name: Fail workflow if tests failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+4 -4
View File
@@ -24,8 +24,8 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -59,7 +59,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
@@ -94,7 +94,7 @@ jobs:
# Surface failing tests
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/packages/lab/**.xml
summary: true
+37 -37
View File
@@ -41,8 +41,8 @@ jobs:
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -106,7 +106,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -123,7 +123,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -153,7 +153,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -177,7 +177,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -186,7 +186,7 @@ jobs:
title: OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -214,7 +214,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -223,7 +223,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -247,7 +247,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -256,7 +256,7 @@ jobs:
title: Azure OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -284,7 +284,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -295,7 +295,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -370,7 +370,7 @@ jobs:
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -379,7 +379,7 @@ jobs:
title: Misc integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -417,7 +417,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -426,7 +426,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -448,7 +448,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -457,7 +457,7 @@ jobs:
title: Functions integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -488,7 +488,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -497,7 +497,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -515,7 +515,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -524,7 +524,7 @@ jobs:
title: Test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -549,7 +549,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -558,7 +558,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -576,7 +576,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -585,7 +585,7 @@ jobs:
title: Foundry Hosting integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -620,7 +620,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -643,7 +643,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -652,7 +652,7 @@ jobs:
title: Cosmos integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -680,19 +680,19 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
@@ -709,13 +709,13 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@v4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: integration-test-report
path: |
@@ -740,13 +740,13 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -56,7 +56,7 @@ jobs:
- name: Build the package
run: uv run poe --directory packages/${{ env.PACKAGE }} build
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
python/dist/*
+37 -37
View File
@@ -29,7 +29,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -49,7 +49,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-01-get-started
@@ -82,7 +82,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -111,7 +111,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents
@@ -130,7 +130,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -152,7 +152,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-openai
@@ -170,7 +170,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -191,7 +191,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-azure
@@ -208,7 +208,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -228,7 +228,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-anthropic
@@ -242,7 +242,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -257,7 +257,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-github-copilot
@@ -274,7 +274,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -289,7 +289,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-amazon
@@ -306,7 +306,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -321,7 +321,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-ollama
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -363,7 +363,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-foundry
@@ -383,7 +383,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -405,7 +405,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-copilotstudio
@@ -419,7 +419,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -434,7 +434,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-02-agents-custom
@@ -451,7 +451,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -471,7 +471,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-03-workflows
@@ -491,7 +491,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -506,7 +506,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-04-hosting
@@ -534,7 +534,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -549,7 +549,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-05-end-to-end
@@ -574,7 +574,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -599,7 +599,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-autogen-migration
@@ -633,7 +633,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -662,7 +662,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-report-semantic-kernel-migration
@@ -690,10 +690,10 @@ jobs:
- validate-autogen-migration
- validate-semantic-kernel-migration
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download all validation reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: validation-report-*
path: reports/
@@ -701,7 +701,7 @@ jobs:
- name: Restore validation history
id: cache-restore
uses: actions/cache/restore@v4
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
@@ -719,13 +719,13 @@ jobs:
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Save validation history
uses: actions/cache/save@v4
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
- name: Upload trend report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: validation-trend-report
@@ -19,9 +19,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download coverage report
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
run-id: ${{ github.event.workflow_run.id }}
@@ -46,7 +46,7 @@ jobs:
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@v1.6.0
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
env:
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# Save the PR number to a file since the workflow_run event
# in the coverage report workflow does not have access to it
- name: Save PR number
@@ -42,7 +42,7 @@ jobs:
- name: Check coverage threshold
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
- name: Upload coverage report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
path: |
python/python-coverage.xml
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -46,7 +46,7 @@ jobs:
# Surface failing tests
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
+2 -2
View File
@@ -31,9 +31,9 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.13'
+28 -10
View File
@@ -28,9 +28,7 @@ public sealed class A2AAgent : AIAgent
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly IA2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
private readonly A2AAgentOptions _agentOptions;
private readonly ILogger _logger;
/// <summary>
@@ -38,17 +36,37 @@ public sealed class A2AAgent : AIAgent
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
: this(
a2aClient,
new A2AAgentOptions
{
Id = id,
Name = name,
Description = description
},
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
_ = Throw.IfNull(options);
this._a2aClient = a2aClient;
this._id = id;
this._name = name;
this._description = description;
this._agentOptions = options.Clone();
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
@@ -216,13 +234,13 @@ public sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
protected override string? IdCore => this._id;
protected override string? IdCore => this._agentOptions.Id;
/// <inheritdoc/>
public override string? Name => this._name;
public override string? Name => this._agentOptions.Name;
/// <inheritdoc/>
public override string? Description => this._description;
public override string? Description => this._agentOptions.Description;
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Represents configuration options for an <see cref="A2AAgent"/>, including its identifier, name, and description.
/// </summary>
/// <remarks>
/// This class is used to encapsulate information about an A2A agent, such as its unique
/// identifier, display name, and a descriptive summary. It provides an alternative to passing
/// these values as individual constructor parameters.
/// </remarks>
public sealed class A2AAgentOptions
{
/// <summary>
/// Gets or sets the agent id.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Gets or sets the agent name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Creates a new instance of <see cref="A2AAgentOptions"/> with the same values as this instance.
/// </summary>
public A2AAgentOptions Clone()
=> new()
{
Id = this.Id,
Name = this.Name,
Description = this.Description
};
}
@@ -2,7 +2,9 @@
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -36,4 +38,39 @@ public static class A2AAgentCardExtensions
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the <see cref="AgentCard"/>.
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the agent card.
/// </param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this AgentCard card, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(card);
_ = Throw.IfNull(agentOptions);
var a2aClient = A2AClientFactory.Create(card, httpClient, clientOptions);
var mergedOptions = agentOptions.Clone();
mergedOptions.Name ??= card.Name;
mergedOptions.Description ??= card.Description;
return a2aClient.AsAIAgent(mergedOptions, loggerFactory);
}
}
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -48,4 +49,39 @@ public static class A2ACardResolverExtensions
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the resolved <see cref="AgentCard"/>.
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the resolved agent card.
/// </param>
/// <param name="httpClient">
/// The <see cref="HttpClient"/> to use for HTTP requests made by the created A2A client.
/// This is not used for fetching the agent card; the resolver uses its own configured client for that.
/// </param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agentOptions);
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(agentOptions, httpClient, clientOptions, loggerFactory);
}
}
@@ -3,6 +3,7 @@
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
@@ -31,10 +32,32 @@ public static class A2AClientExtensions
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
/// discovery mechanism.
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(client);
_ = Throw.IfNull(options);
return new A2AAgent(client, options, loggerFactory);
}
}
@@ -83,6 +83,72 @@ public sealed class A2AAgentTests : IDisposable
Assert.Null(agent.Description);
}
[Fact]
public void Constructor_WithOptions_InitializesPropertiesCorrectly()
{
// Arrange
var options = new A2AAgentOptions
{
Id = "options-id",
Name = "options-name",
Description = "options-description"
};
// Act
var agent = new A2AAgent(this._a2aClient, options);
// Assert
Assert.Equal("options-id", agent.Id);
Assert.Equal("options-name", agent.Name);
Assert.Equal("options-description", agent.Description);
}
[Fact]
public void Constructor_WithOptions_IsolatesAgentFromOptionsMutation()
{
// Arrange
var options = new A2AAgentOptions
{
Id = "original-id",
Name = "Original Name",
Description = "Original Description"
};
var agent = new A2AAgent(this._a2aClient, options);
// Act - mutate options after agent construction
options.Id = "mutated-id";
options.Name = "Mutated Name";
options.Description = "Mutated Description";
// Assert - agent should retain original values
Assert.Equal("original-id", agent.Id);
Assert.Equal("Original Name", agent.Name);
Assert.Equal("Original Description", agent.Description);
}
[Fact]
public void Constructor_WithNullOptions_ThrowsArgumentNullException() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(this._a2aClient, options: null!));
[Fact]
public void Constructor_WithEmptyOptions_UsesBaseProperties()
{
// Act
var agent = new A2AAgent(this._a2aClient, new A2AAgentOptions());
// Assert
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
}
[Fact]
public void Constructor_WithOptions_NullA2AClient_ThrowsArgumentNullException() =>
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!, new A2AAgentOptions()));
[Fact]
public async Task RunAsync_AllowsNonUserRoleMessagesAsync()
{
@@ -31,7 +31,7 @@ public sealed class A2AAgentCardExtensionsTests
}
[Fact]
public void GetAIAgent_ReturnsAIAgent()
public void AsAIAgent_ReturnsAIAgent()
{
// Act
var agent = this._agentCard.AsAIAgent();
@@ -165,6 +165,81 @@ public sealed class A2AAgentCardExtensionsTests
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
}
[Fact]
public void AsAIAgent_WithAgentOptions_OverridesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id",
Name = "Custom Agent",
Description = "Custom description"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Custom Agent", agent.Name);
Assert.Equal("Custom description", agent.Description);
}
[Fact]
public void AsAIAgent_WithAgentOptions_FallsBackToCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
[Fact]
public void AsAIAgent_WithEmptyAgentOptions_UsesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
// Act
var agent = card.AsAIAgent(new A2AAgentOptions());
// Assert
Assert.NotNull(agent);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
@@ -113,6 +113,61 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
}
[Fact]
public async Task GetAIAgentAsync_WithAgentOptions_OverridesCardValuesAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
var agentOptions = new A2AAgentOptions
{
Id = "custom-id",
Name = "Custom Agent",
Description = "Custom description"
};
// Act
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Custom Agent", agent.Name);
Assert.Equal("Custom description", agent.Description);
}
[Fact]
public async Task GetAIAgentAsync_WithAgentOptions_FallsBackToCardValuesAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
var agentOptions = new A2AAgentOptions
{
Id = "custom-id"
};
// Act
var agent = await this._resolver.GetAIAgentAsync(agentOptions, httpClient: this._httpClient);
// Assert
Assert.NotNull(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
public void Dispose()
{
this._handler.Dispose();
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.A2A.UnitTests;
public sealed class A2AClientExtensionsTests
{
[Fact]
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
public void AsAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -32,7 +32,7 @@ public sealed class A2AClientExtensionsTests
}
[Fact]
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
public void AsAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -53,7 +53,7 @@ public sealed class A2AClientExtensionsTests
}
[Fact]
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
public void AsAIAgent_WithIA2AClient_ExposesClientViaGetService()
{
// Arrange
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
@@ -66,4 +66,45 @@ public sealed class A2AClientExtensionsTests
Assert.NotNull(service);
Assert.Same(a2aClient, service);
}
[Fact]
public void AsAIAgent_WithOptions_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
var options = new A2AAgentOptions
{
Id = "options-agent-id",
Name = "Options Agent",
Description = "Agent created with options"
};
// Act
var agent = a2aClient.AsAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("options-agent-id", agent.Id);
Assert.Equal("Options Agent", agent.Name);
Assert.Equal("Agent created with options", agent.Description);
}
[Fact]
public void AsAIAgent_WithEmptyOptions_ReturnsA2AAgentWithDefaultProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
// Act
var agent = a2aClient.AsAIAgent(new A2AAgentOptions());
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
}
}
+1 -2
View File
@@ -44,7 +44,6 @@ GEMINI_MODEL=""
# Ollama
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
# Observability
ENABLE_INSTRUMENTATION=true
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
ENABLE_SENSITIVE_DATA=true
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
+1 -1
View File
@@ -72,7 +72,7 @@ def equal(arg1: str, arg2: str) -> bool:
from agent_framework import Agent, Message, tool
# Components
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
# Connectors (lazy-loaded)
from agent_framework.openai import OpenAIChatClient
+1
View File
@@ -93,3 +93,4 @@ python/
### Experimental
- [lab](packages/lab/AGENTS.md) - Experimental features
- [monty](packages/monty/AGENTS.md) - Monty-backed CodeAct integrations (alpha)
+1 -1
View File
@@ -186,7 +186,7 @@ The package follows a flat import structure:
- **Components**: Import from `agent_framework.<component>`
```python
from agent_framework.observability import enable_instrumentation, configure_otel_providers
from agent_framework.observability import enable_sensitive_telemetry, configure_otel_providers
```
- **Connectors**: Import from `agent_framework.<vendor/platform>`
+1
View File
@@ -37,6 +37,7 @@ Status is grouped into these buckets:
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
| `agent-framework-openai` | `python/packages/openai` | `released` |
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` |
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0rc1"
version = "1.0.0rc2"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -5,7 +5,6 @@
from __future__ import annotations
import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
from agent_framework import (
@@ -31,11 +30,6 @@ from chatkit.types import (
WorkflowItem,
)
if sys.version_info >= (3, 11):
from typing import assert_never # type:ignore # pragma: no cover
else:
from typing_extensions import assert_never # type:ignore # pragma: no cover
logger = logging.getLogger(__name__)
@@ -532,7 +526,10 @@ class ThreadItemConverter:
# TODO(evmattso): Implement structured input handling in a future PR
return []
case _:
assert_never(item)
# Unknown ThreadItem variant (e.g. types added in newer chatkit versions).
# Skip rather than fail so we remain forward-compatible with chatkit upgrades.
logger.debug("Skipping unsupported ThreadItem of type %s", type(item).__name__)
return []
async def to_agent_input(
self,
+150 -24
View File
@@ -255,7 +255,7 @@ class MCPTool:
self._exit_stack = AsyncExitStack()
self._lifecycle_lock = asyncio.Lock()
self._lifecycle_request_lock = asyncio.Lock()
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, asyncio.Future[None]]] | None = None
self._lifecycle_queue: asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None]]] | None = None
self._lifecycle_owner_task: asyncio.Task[None] | None = None
self.session = session
self.request_timeout = request_timeout
@@ -265,6 +265,11 @@ class MCPTool:
self.is_connected: bool = False
self._tools_loaded: bool = False
self._prompts_loaded: bool = False
self._server_capabilities: types.ServerCapabilities | None = None
self._supports_tools: bool = True
self._supports_prompts: bool = True
self._supports_logging: bool | None = None
self._ping_available: bool = True
self._pending_reload_tasks: set[asyncio.Task[None]] = set()
def __str__(self) -> str:
@@ -566,11 +571,11 @@ class MCPTool:
stop_error: BaseException | None = None
try:
while True:
action, reset, future = await queue.get()
action, reset, load_configured, future = await queue.get()
try:
if action == "connect":
await self._connect_on_owner(reset=reset)
await self._connect_on_owner(reset=reset, load_configured=load_configured)
elif action == "close":
await self._close_on_owner()
else:
@@ -595,7 +600,7 @@ class MCPTool:
finally:
while True:
try:
_, _, future = queue.get_nowait()
_, _, _, future = queue.get_nowait()
except asyncio.QueueEmpty:
break
if not future.done():
@@ -608,12 +613,18 @@ class MCPTool:
owner_task = self._lifecycle_owner_task
return owner_task is not None and asyncio.current_task() is owner_task
async def _run_on_lifecycle_owner(self, action: str, *, reset: bool = False) -> None:
async def _run_on_lifecycle_owner(
self,
action: str,
*,
reset: bool = False,
load_configured: bool = True,
) -> None:
await self._ensure_lifecycle_owner()
if self._is_lifecycle_owner_task():
if action == "connect":
await self._connect_on_owner(reset=reset)
await self._connect_on_owner(reset=reset, load_configured=load_configured)
elif action == "close":
await self._close_on_owner()
else:
@@ -625,7 +636,7 @@ class MCPTool:
raise RuntimeError("MCP lifecycle owner is not available.")
future = asyncio.get_running_loop().create_future()
await queue.put((action, reset, future))
await queue.put((action, reset, load_configured, future))
await future
async def _safe_close_exit_stack(self) -> None:
@@ -656,6 +667,32 @@ class MCPTool:
await self._safe_close_exit_stack()
return _should_propagate_cancelled_error(ex)
def _reset_session_state(self) -> None:
self._server_capabilities = None
self._supports_tools = True
self._supports_prompts = True
self._supports_logging = None
self._ping_available = True
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
self._server_capabilities = capabilities
if capabilities is None:
self._supports_tools = False
self._supports_prompts = False
self._supports_logging = False
return
self._supports_tools = getattr(capabilities, "tools", None) is not None
self._supports_prompts = getattr(capabilities, "prompts", None) is not None
self._supports_logging = getattr(capabilities, "logging", None) is not None
async def _reconnect_without_loading(self) -> None:
if self._is_lifecycle_owner_task():
await self._connect_on_owner(reset=True, load_configured=False)
return
await self._run_on_lifecycle_owner("connect", reset=True, load_configured=False)
async def connect(self, *, reset: bool = False) -> None:
if self._is_lifecycle_owner_task():
await self._connect_on_owner(reset=reset)
@@ -664,7 +701,7 @@ class MCPTool:
async with self._lifecycle_request_lock:
await self._run_on_lifecycle_owner("connect", reset=reset)
async def _connect_on_owner(self, *, reset: bool = False) -> None:
async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool = True) -> None:
"""Connect to the MCP server.
Establishes a connection to the MCP server, initializes the session,
@@ -672,6 +709,7 @@ class MCPTool:
Keyword Args:
reset: If True, forces a reconnection even if already connected.
load_configured: If True, loads tools and prompts according to the constructor flags.
Raises:
ToolException: If connection or session initialization fails.
@@ -680,6 +718,7 @@ class MCPTool:
await self._safe_close_exit_stack()
self.session = None
self.is_connected = False
self._reset_session_state()
self._exit_stack = AsyncExitStack()
if not self.session:
try:
@@ -741,7 +780,8 @@ class MCPTool:
inner_exception=ex if isinstance(ex, Exception) else None,
) from ex
try:
await session.initialize()
initialize_result = await session.initialize()
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
except (Exception, asyncio.CancelledError) as ex:
if await self._close_and_check_cancelled(ex):
raise
@@ -759,17 +799,22 @@ class MCPTool:
self.session = session
elif self.session._request_id == 0: # type: ignore[attr-defined]
# If the session is not initialized, we need to reinitialize it
await self.session.initialize()
initialize_result = await self.session.initialize()
self._set_server_capabilities(getattr(initialize_result, "capabilities", None))
elif self._server_capabilities is None:
self._set_server_capabilities(getattr(self.session, "_server_capabilities", None))
logger.debug("Connected to MCP server: %s", self.session)
self.is_connected = True
if self.load_tools_flag:
await self.load_tools()
if load_configured and self.load_tools_flag:
if self._supports_tools:
await self.load_tools()
self._tools_loaded = True
if self.load_prompts_flag:
await self.load_prompts()
if load_configured and self.load_prompts_flag:
if self._supports_prompts:
await self.load_prompts()
self._prompts_loaded = True
if logger.level != logging.NOTSET:
if logger.level != logging.NOTSET and self._supports_logging is not False:
try:
level_name = cast(
Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
@@ -973,17 +1018,49 @@ class MCPTool:
Raises:
ToolExecutionException: If the MCP server is not connected.
"""
from anyio import ClosedResourceError
from mcp import types
if not self._supports_prompts:
logger.debug("Skipping MCP prompt loading because the server did not advertise prompts support.")
return
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
params: types.PaginatedRequestParams | None = None
while True:
# Ensure connection is still valid before each page request
await self._ensure_connected()
prompt_list: types.ListPromptsResult | None = None
for attempt in range(2):
try:
# Ensure connection is still valid before each page request
await self._ensure_connected()
if not self._supports_prompts:
logger.debug(
"Skipping MCP prompt loading because the server did not advertise prompts support."
)
return
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
break
except ClosedResourceError as cl_ex:
if attempt == 0:
logger.info("MCP connection closed unexpectedly while loading prompts. Reconnecting...")
try:
await self._reconnect_without_loading()
except Exception as reconn_ex:
raise ToolExecutionException(
"Failed to reconnect to MCP server.",
inner_exception=reconn_ex,
) from reconn_ex
continue
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
raise ToolExecutionException(
"Failed to load prompts - connection lost.",
inner_exception=cl_ex,
) from cl_ex
prompt_list = await self.session.list_prompts(params=params) # type: ignore[union-attr]
if prompt_list is None:
raise ToolExecutionException("Failed to load prompts.")
for prompt in prompt_list.prompts:
normalized_name = _normalize_mcp_name(prompt.name)
@@ -1010,7 +1087,7 @@ class MCPTool:
existing_names.add(local_name)
# Check if there are more pages
if not prompt_list or not prompt_list.nextCursor:
if not prompt_list.nextCursor:
break
params = types.PaginatedRequestParams(cursor=prompt_list.nextCursor)
@@ -1023,18 +1100,48 @@ class MCPTool:
Raises:
ToolExecutionException: If the MCP server is not connected.
"""
from anyio import ClosedResourceError
from mcp import types
if not self._supports_tools:
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
return
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
self._tool_call_meta_by_name.clear()
params: types.PaginatedRequestParams | None = None
while True:
# Ensure connection is still valid before each page request
await self._ensure_connected()
tool_list: types.ListToolsResult | None = None
for attempt in range(2):
try:
# Ensure connection is still valid before each page request
await self._ensure_connected()
if not self._supports_tools:
logger.debug("Skipping MCP tool loading because the server did not advertise tools support.")
return
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
break
except ClosedResourceError as cl_ex:
if attempt == 0:
logger.info("MCP connection closed unexpectedly while loading tools. Reconnecting...")
try:
await self._reconnect_without_loading()
except Exception as reconn_ex:
raise ToolExecutionException(
"Failed to reconnect to MCP server.",
inner_exception=reconn_ex,
) from reconn_ex
continue
logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex)
raise ToolExecutionException(
"Failed to load tools - connection lost.",
inner_exception=cl_ex,
) from cl_ex
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
if tool_list is None:
raise ToolExecutionException("Failed to load tools.")
for tool in tool_list.tools:
if tool.meta is not None:
@@ -1083,7 +1190,7 @@ class MCPTool:
existing_names.add(local_name)
# Check if there are more pages
if not tool_list or not tool_list.nextCursor:
if not tool_list.nextCursor:
break
params = types.PaginatedRequestParams(cursor=tool_list.nextCursor)
@@ -1100,6 +1207,7 @@ class MCPTool:
self._exit_stack = AsyncExitStack()
self.session = None
self.is_connected = False
self._reset_session_state()
async def close(self) -> None:
"""Disconnect from the MCP server.
@@ -1131,12 +1239,30 @@ class MCPTool:
Raises:
ToolExecutionException: If reconnection fails.
"""
from mcp.shared.exceptions import McpError
if not self._ping_available:
return
try:
await self.session.send_ping() # type: ignore[union-attr]
except McpError as mcp_exc:
if mcp_exc.error.code == -32601:
self._ping_available = False
logger.debug("Skipping future MCP pings because the server does not support ping.")
return
logger.info("MCP connection invalid or closed. Reconnecting...")
try:
await self._reconnect_without_loading()
except Exception as ex:
raise ToolExecutionException(
"Failed to establish MCP connection.",
inner_exception=ex,
) from ex
except Exception:
logger.info("MCP connection invalid or closed. Reconnecting...")
try:
await self.connect(reset=True)
await self._reconnect_without_loading()
except Exception as ex:
raise ToolExecutionException(
"Failed to establish MCP connection.",
@@ -4,6 +4,8 @@
Commonly used exports:
- enable_instrumentation
- disable_instrumentation
- enable_sensitive_telemetry
- configure_otel_providers
- AgentTelemetryLayer
- ChatTelemetryLayer
@@ -80,7 +82,9 @@ __all__ = [
"configure_otel_providers",
"create_metric_views",
"create_resource",
"disable_instrumentation",
"enable_instrumentation",
"enable_sensitive_telemetry",
"get_meter",
"get_tracer",
]
@@ -643,8 +647,8 @@ class ObservabilitySettings:
Sensitive events should only be enabled on test and development environments.
Keyword Args:
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is False.
Can be set via environment variable ENABLE_INSTRUMENTATION.
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is True.
Can be disabled by setting environment variable ENABLE_INSTRUMENTATION=false.
enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False.
Can be set via environment variable ENABLE_SENSITIVE_DATA.
enable_console_exporters: Enable console exporters for traces, logs, and metrics.
@@ -659,12 +663,12 @@ class ObservabilitySettings:
from agent_framework import ObservabilitySettings
# Using environment variables
# Set ENABLE_INSTRUMENTATION=true
# Instrumentation is enabled by default; set ENABLE_INSTRUMENTATION=false to disable.
# Set ENABLE_CONSOLE_EXPORTERS=true
settings = ObservabilitySettings()
# Or passing parameters directly
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
settings = ObservabilitySettings(enable_console_exporters=True)
"""
def __init__(self, **kwargs: Any) -> None:
@@ -677,14 +681,74 @@ class ObservabilitySettings:
env_file_encoding=env_file_encoding,
**kwargs,
)
self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
# Sticky-disable flag, set by `disable_instrumentation()`. When True, this
# singleton refuses to be re-enabled by any subsequent assignment to the
# `enable_instrumentation` / `enable_sensitive_data` properties (including
# direct third-party writes). It can only be cleared by an explicit
# `enable_instrumentation(force=True)` / `enable_sensitive_telemetry(force=True)`
# call, which is the user re-stating their intent.
self._user_disabled: bool = False
# `enable_instrumentation` is defaulted to True if not set
instrumentation_value = data.get("enable_instrumentation")
self._enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value
self._enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
if self._enable_sensitive_data and not self._enable_instrumentation:
logger.warning(
"Sensitive data capture is enabled but instrumentation is disabled. "
"Sensitive data will not be captured. Please enable instrumentation to capture sensitive data."
)
self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
self.env_file_path = env_file_path
self.env_file_encoding = env_file_encoding
self._executed_setup = False
@property
def enable_instrumentation(self) -> bool:
"""Whether instrumentation is enabled.
Always returns False once ``disable_instrumentation()`` has been called,
regardless of the stored value, until ``enable_instrumentation(force=True)``
clears the sticky disable.
"""
if self._user_disabled:
return False
return self._enable_instrumentation
@enable_instrumentation.setter
def enable_instrumentation(self, value: bool) -> None:
if self._user_disabled and value:
# Defense in depth: a third-party (or internal) write of True is
# silently dropped while the user-disabled flag is set, so the
# sticky disable cannot be circumvented by direct attribute writes.
logger.debug(
"Ignoring enable_instrumentation=True assignment: instrumentation was explicitly disabled via "
"disable_instrumentation(). Call enable_instrumentation(force=True) to clear the disable."
)
return
self._enable_instrumentation = value
@property
def enable_sensitive_data(self) -> bool:
"""Whether sensitive-data capture is enabled.
Always returns False once ``disable_instrumentation()`` has been called.
"""
if self._user_disabled:
return False
return self._enable_sensitive_data
@enable_sensitive_data.setter
def enable_sensitive_data(self, value: bool) -> None:
if self._user_disabled and value:
logger.debug(
"Ignoring enable_sensitive_data=True assignment: instrumentation was explicitly disabled via "
"disable_instrumentation(). Call enable_sensitive_telemetry(force=True) to clear the disable."
)
return
self._enable_sensitive_data = value
@property
def ENABLED(self) -> bool:
"""Check if model diagnostics are enabled.
@@ -706,6 +770,17 @@ class ObservabilitySettings:
"""Check if the setup has been executed."""
return self._executed_setup
@property
def is_user_disabled(self) -> bool:
"""Whether ``disable_instrumentation()`` has been called and the disable is still in effect.
Integrations that perform telemetry setup as a side-effect (e.g. provisioning Azure Monitor
providers from a Foundry project's connection string) should consult this flag before doing
their setup work, so the user's explicit opt-out is respected end-to-end and not just at the
framework's span-emission boundary.
"""
return self._user_disabled
def _configure(
self,
*,
@@ -951,24 +1026,91 @@ def _read_int_env(name: str, *, default: int | None = None) -> int | None:
return default
def enable_sensitive_telemetry(*, force: bool = False) -> None:
"""Enable capture of sensitive data in telemetry for your application.
Instrumentation is enabled by default; this method exists to opt-in to capturing
sensitive event payloads (e.g., chat messages, tool arguments).
This method does not configure exporters or providers. It also ensures that
instrumentation is enabled (in case it was explicitly disabled via the
ENABLE_INSTRUMENTATION environment variable).
Keyword Args:
force: When True, clears any sticky disable previously set by
``disable_instrumentation()`` before enabling. Without it, calls are
no-ops if instrumentation has been explicitly disabled.
Warning:
Sensitive events should only be enabled on test and development environments.
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
logger.info(
"enable_sensitive_telemetry() ignored: instrumentation was explicitly disabled via "
"disable_instrumentation(). Pass force=True to re-enable."
)
return
if force:
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS.enable_instrumentation = True
OBSERVABILITY_SETTINGS.enable_sensitive_data = True
def disable_instrumentation() -> None:
"""Explicitly disable Agent Framework instrumentation for this process.
The disable is **sticky**: subsequent attempts by framework auto-setup paths,
library integrations, ``enable_instrumentation()``, ``enable_sensitive_telemetry()``,
``configure_otel_providers()``, or direct writes to
``OBSERVABILITY_SETTINGS.enable_instrumentation`` are ignored and no spans, metrics,
or logs are emitted by Agent Framework code paths.
To override the disable later, call ``enable_instrumentation(force=True)`` or
``enable_sensitive_telemetry(force=True)``. This makes the user's intent to opt out
win against framework code that would otherwise re-enable instrumentation
automatically.
Note:
Disabling does not tear down already-configured OpenTelemetry providers,
exporters, or in-flight spans; it gates future captures by Agent Framework
instrumentation only. To stop emitting telemetry from third-party
instrumentations as well, configure them separately.
"""
global OBSERVABILITY_SETTINGS
OBSERVABILITY_SETTINGS._user_disabled = True # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS._enable_instrumentation = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS._enable_sensitive_data = False # type: ignore[reportPrivateUsage]
def enable_instrumentation(
*,
enable_sensitive_data: bool | None = None,
force: bool = False,
) -> None:
"""Enable instrumentation for your application.
"""Enable instrumentation for Microsoft Agent Framework.
Calling this method implies you want to enable observability in your application.
This method does not configure exporters or providers.
It only updates the global variables that trigger the instrumentation code.
If you have already set the environment variable ENABLE_INSTRUMENTATION=true,
calling this method has no effect, unless you want to enable or disable sensitive data events.
Note that instrumentation is enabled by default, so this method is only necessary
if you need a programmatic way to enable it (e.g., if you are not sure whether the
environment variable ENABLE_INSTRUMENTATION is set to True or False and want to
ensure it is enabled).
Keyword Args:
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
the environment variable ENABLE_SENSITIVE_DATA if set. Default is None.
force: When True, clears any sticky disable previously set by
``disable_instrumentation()`` before enabling. Without it, calls are
no-ops if instrumentation has been explicitly disabled.
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
logger.info(
"enable_instrumentation() ignored: instrumentation was explicitly disabled via "
"disable_instrumentation(). Pass force=True to re-enable."
)
return
if force:
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS.enable_instrumentation = True
if enable_sensitive_data is not None:
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
@@ -1008,7 +1150,7 @@ def configure_otel_providers(
Since you can only setup one provider per signal type (logs, traces, metrics),
you can choose to use this method and take the exporter and provider that we created.
Alternatively, you can setup the providers yourself, or through another library
(e.g., Azure Monitor) and just call `enable_instrumentation()` to enable instrumentation.
(e.g., Azure Monitor) and just call `enable_sensitive_telemetry()` to opt-in to sensitive data capture.
Note:
By default, the Agent Framework emits metrics with the prefixes `agent_framework`
@@ -1042,7 +1184,6 @@ def configure_otel_providers(
from agent_framework.observability import configure_otel_providers
# Using environment variables (recommended)
# Set ENABLE_INSTRUMENTATION=true
# Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
configure_otel_providers()
@@ -1087,18 +1228,25 @@ def configure_otel_providers(
.. code-block:: python
# when azure monitor is installed
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from azure.monitor.opentelemetry import configure_azure_monitor
connection_string = "InstrumentationKey=your_instrumentation_key_here;..."
configure_azure_monitor(connection_string=connection_string)
enable_instrumentation()
# Optional: opt into capturing sensitive data
enable_sensitive_telemetry()
References:
- https://opentelemetry.io/docs/languages/sdk-configuration/general/
- https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled: # type: ignore[reportPrivateUsage]
logger.info(
"configure_otel_providers(): instrumentation was explicitly disabled via "
"disable_instrumentation(); providers and exporters will still be configured but "
"Agent Framework will emit no telemetry until enable_instrumentation(force=True) is called."
)
if env_file_path:
# Build kwargs, excluding None values
settings_kwargs: dict[str, Any] = {
@@ -1280,7 +1428,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
if stream:
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1344,6 +1492,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
and isinstance(response, ChatResponse)
and response.messages
and span.is_recording()
):
_capture_messages(
span=span,
@@ -1374,7 +1523,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
async def _get_response() -> ChatResponse:
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.REQUEST_MODEL) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1408,7 +1557,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
duration=duration,
)
_mark_inner_response_telemetry_captured(response)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
finish_reason = cast(
"FinishReason | None",
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
@@ -1552,7 +1701,7 @@ class AgentTelemetryLayer:
if stream:
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1613,6 +1762,7 @@ class AgentTelemetryLayer:
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
and isinstance(response, AgentResponse)
and response.messages
and span.is_recording()
):
_capture_messages(
span=span,
@@ -1645,7 +1795,7 @@ class AgentTelemetryLayer:
async def _run() -> AgentResponse[Any]:
try:
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1669,7 +1819,7 @@ class AgentTelemetryLayer:
)
_apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields)
_capture_response(span=span, attributes=response_attributes, duration=duration)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
+149 -3
View File
@@ -4031,14 +4031,102 @@ async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompt
assert tool._prompts_loaded is True
async def test_connect_skips_tools_and_prompts_when_server_does_not_advertise_capabilities() -> None:
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
tool.is_connected = True
tool.session = Mock()
tool.session._request_id = 0
tool.session.initialize = AsyncMock(
return_value=types.InitializeResult(
protocolVersion=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ServerCapabilities(),
serverInfo=types.Implementation(name="test", version="1.0"),
)
)
tool.session.list_tools = AsyncMock()
tool.session.list_prompts = AsyncMock()
tool.session.set_logging_level = AsyncMock()
with patch.object(logger, "level", logging.INFO):
await tool._connect_on_owner()
tool.session.initialize.assert_awaited_once()
tool.session.list_tools.assert_not_called()
tool.session.list_prompts.assert_not_called()
tool.session.set_logging_level.assert_not_called()
assert tool.is_connected is True
assert tool._supports_tools is False
assert tool._supports_prompts is False
assert tool._supports_logging is False
assert tool._tools_loaded is True
assert tool._prompts_loaded is True
async def test_connect_treats_missing_capabilities_as_unsupported() -> None:
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
tool.is_connected = True
tool.session = Mock()
tool.session._request_id = 0
tool.session.initialize = AsyncMock(return_value=Mock(capabilities=None))
tool.session.list_tools = AsyncMock()
tool.session.list_prompts = AsyncMock()
with patch.object(logger, "level", logging.NOTSET):
await tool._connect_on_owner()
tool.session.list_tools.assert_not_called()
tool.session.list_prompts.assert_not_called()
assert tool._supports_tools is False
assert tool._supports_prompts is False
assert tool._supports_logging is False
async def test_connect_sets_logging_level_when_server_advertises_logging() -> None:
tool = MCPTool(name="test_tool", load_tools=False, load_prompts=False)
tool.is_connected = True
tool.session = Mock()
tool.session._request_id = 0
tool.session.initialize = AsyncMock(
return_value=types.InitializeResult(
protocolVersion=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ServerCapabilities(logging=types.LoggingCapability()),
serverInfo=types.Implementation(name="test", version="1.0"),
)
)
tool.session.set_logging_level = AsyncMock()
with patch.object(logger, "level", logging.INFO):
await tool._connect_on_owner()
tool.session.set_logging_level.assert_awaited_once_with("info")
assert tool._supports_logging is True
async def test_ensure_connected_skips_future_pings_when_ping_is_not_available() -> None:
tool = MCPTool(name="test_tool")
tool.session = Mock(
send_ping=AsyncMock(
side_effect=McpError(types.ErrorData(code=-32601, message="Method 'ping' is not available."))
)
)
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
await tool._ensure_connected()
await tool._ensure_connected()
tool.session.send_ping.assert_awaited_once()
mock_reconnect.assert_not_awaited()
assert tool._ping_available is False
async def test_ensure_connected_reconnects_on_failed_ping() -> None:
tool = MCPTool(name="test_tool")
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
with patch.object(tool, "connect", AsyncMock()) as mock_connect:
with patch.object(tool, "_reconnect_without_loading", AsyncMock()) as mock_reconnect:
await tool._ensure_connected()
mock_connect.assert_awaited_once_with(reset=True)
mock_reconnect.assert_awaited_once_with()
async def test_ensure_connected_wraps_reconnect_failure() -> None:
@@ -4046,12 +4134,70 @@ async def test_ensure_connected_wraps_reconnect_failure() -> None:
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
with (
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))),
patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=RuntimeError("still closed"))),
pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"),
):
await tool._ensure_connected()
async def test_load_tools_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
from anyio import ClosedResourceError
tool = MCPTool(name="test_tool", load_tools=True)
tool._ping_available = False
first_session = Mock()
first_session.list_tools = AsyncMock(side_effect=ClosedResourceError())
tool.session = first_session
page = Mock()
page.tools = []
page.nextCursor = None
second_session = Mock()
second_session.list_tools = AsyncMock(return_value=page)
async def reconnect() -> None:
tool.session = second_session
tool._supports_tools = True
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
await tool.load_tools()
first_session.list_tools.assert_awaited_once()
mock_reconnect.assert_awaited_once_with()
second_session.list_tools.assert_awaited_once()
async def test_load_prompts_reconnects_on_closed_resource_when_ping_is_unavailable() -> None:
from anyio import ClosedResourceError
tool = MCPTool(name="test_tool", load_prompts=True)
tool._ping_available = False
first_session = Mock()
first_session.list_prompts = AsyncMock(side_effect=ClosedResourceError())
tool.session = first_session
page = Mock()
page.prompts = []
page.nextCursor = None
second_session = Mock()
second_session.list_prompts = AsyncMock(return_value=page)
async def reconnect() -> None:
tool.session = second_session
tool._supports_prompts = True
with patch.object(tool, "_reconnect_without_loading", AsyncMock(side_effect=reconnect)) as mock_reconnect:
await tool.load_prompts()
first_session.list_prompts.assert_awaited_once()
mock_reconnect.assert_awaited_once_with()
second_session.list_prompts.assert_awaited_once()
async def test_mcp_tool_filters_framework_kwargs():
"""Test that call_tool filters out framework-specific kwargs before calling MCP session.
@@ -1015,11 +1015,25 @@ def test_observability_settings_is_setup_initial(monkeypatch):
assert settings.is_setup is False
# region Test enable_instrumentation function
def test_enable_sensitive_telemetry_function(monkeypatch):
"""Test enable_sensitive_telemetry function enables instrumentation."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_instrumentation_function(monkeypatch):
"""Test enable_instrumentation function enables instrumentation."""
"""Test enable_instrumentation function enables instrumentation when disabled via env."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
@@ -1032,10 +1046,12 @@ def test_enable_instrumentation_function(monkeypatch):
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
# Sensitive data should remain False when not explicitly enabled
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_with_sensitive_data(monkeypatch):
"""Test enable_instrumentation function with sensitive_data parameter."""
"""Test enable_instrumentation function with explicit sensitive_data parameter."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
@@ -1049,111 +1065,6 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch):
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
"""Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
import importlib
from unittest.mock import patch as mock_patch
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
"""Test that explicit parameters to configure_otel_providers override env vars."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Explicit False should override the env var True
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=False)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch):
"""Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var."""
import importlib
@@ -1269,6 +1180,161 @@ def test_enable_instrumentation_preserves_console_exporters_after_env_removed(mo
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
import importlib
from unittest.mock import patch as mock_patch
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
"""Test that explicit parameters to configure_otel_providers override env vars."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Explicit False should override the env var True
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=False)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_sensitive_telemetry_does_not_touch_console_exporters(monkeypatch):
"""Test enable_sensitive_telemetry does not modify enable_console_exporters (it is an exporter concern)."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
observability.enable_sensitive_telemetry()
# enable_console_exporters is not managed by enable_sensitive_telemetry;
# it is only read by configure_otel_providers.
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
def test_enable_sensitive_telemetry_does_not_clobber_console_exporters(monkeypatch):
"""Test enable_sensitive_telemetry does not reset enable_console_exporters set by prior configure call."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Set console exporters via configure_otel_providers
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_console_exporters=True)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Calling enable_sensitive_telemetry should not clobber the value
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_enable_sensitive_telemetry_preserves_console_exporters_after_env_removed(monkeypatch):
"""Test enable_sensitive_telemetry preserves enable_console_exporters when env var is removed after reload."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Remove the env var after reload
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
# enable_sensitive_telemetry should not reset the value
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed."""
import importlib
@@ -1321,6 +1387,189 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
# region Test default-on instrumentation
def test_observability_settings_defaults_instrumentation_true(monkeypatch):
"""ENABLE_INSTRUMENTATION unset → ObservabilitySettings defaults to True."""
from agent_framework.observability import ObservabilitySettings
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
settings = ObservabilitySettings()
assert settings.enable_instrumentation is True
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
"""No-arg enable_instrumentation() re-reads ENABLE_SENSITIVE_DATA from env at call time.
Covers the fallback branch where the env var is set AFTER import (e.g. via load_dotenv()).
"""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Simulate load_dotenv() setting the env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
# region Test disable_instrumentation sticky behavior
def test_disable_instrumentation_flips_settings_off(monkeypatch):
"""disable_instrumentation() immediately turns instrumentation and sensitive data off."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is True
observability.disable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is False
assert observability.OBSERVABILITY_SETTINGS.ENABLED is False
def test_disable_instrumentation_is_sticky_against_enable_instrumentation(monkeypatch):
"""Sticky disable: enable_instrumentation() without force is a no-op after disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_is_sticky_against_enable_sensitive_telemetry(monkeypatch):
"""Sticky disable: enable_sensitive_telemetry() without force is a no-op after disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_is_sticky_against_configure_otel_providers(monkeypatch):
"""Sticky disable: configure_otel_providers() does not flip instrumentation back on."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_intercepts_direct_attribute_writes(monkeypatch):
"""Sticky disable: direct OBSERVABILITY_SETTINGS.enable_instrumentation = True is intercepted."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.OBSERVABILITY_SETTINGS.enable_instrumentation = True
observability.OBSERVABILITY_SETTINGS.enable_sensitive_data = True
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_force_clears_disable(monkeypatch):
"""enable_instrumentation(force=True) clears the sticky disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(force=True, enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_sensitive_telemetry_force_clears_disable(monkeypatch):
"""enable_sensitive_telemetry(force=True) clears the sticky disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_sensitive_telemetry(force=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_disable_instrumentation_persists_after_force_until_redisabled(monkeypatch):
"""After force-enable then disable again, the sticky disable is re-armed."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(force=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
observability.disable_instrumentation()
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
def test_disable_instrumentation_in_all(monkeypatch):
"""disable_instrumentation must be re-exported from the module's __all__."""
import agent_framework.observability as observability
assert "disable_instrumentation" in observability.__all__
assert callable(observability.disable_instrumentation)
# region Test _to_otel_part content types
@@ -3797,3 +4046,135 @@ async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvar
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(agent_spans) == 1
assert agent_spans[0].status.status_code == StatusCode.ERROR
# region Test heavy operations skipped when span is not recording
#
# When ``ENABLE_INSTRUMENTATION`` is on (the default) but no OpenTelemetry
# tracer provider has been configured, the global provider is the
# ``ProxyTracerProvider`` which returns non-recording spans. The telemetry
# layers gate sensitive-data serialization (``_capture_messages``) on
# ``span.is_recording()`` so that we don't pay the JSON-serialization cost
# when the span is going to be dropped anyway. The tests below verify that
# behavior by patching ``get_tracer`` to return a ``NoOpTracer``.
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_capture_messages_skipped_when_span_not_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Heavy message serialization is skipped when no provider is configured (non-streaming)."""
from opentelemetry.trace import NoOpTracer
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await client.get_response(messages=messages, options={"model": "Test"})
assert response is not None
# Sensitive-data serialization must be skipped because span.is_recording() is False.
assert mock_capture_messages.call_count == 0
# _capture_response still runs so that metric histograms continue to record.
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_streaming_capture_messages_skipped_when_span_not_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Heavy message serialization is skipped when no provider is configured (streaming)."""
from opentelemetry.trace import NoOpTracer
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
updates: list[ChatResponseUpdate] = []
stream = client.get_response(messages=messages, stream=True, options={"model": "Test"})
async for update in stream:
updates.append(update)
await stream.get_final_response()
assert len(updates) == 2
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_capture_messages_skipped_when_span_not_recording(
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Agent heavy serialization is skipped when no provider is configured (non-streaming)."""
from opentelemetry.trace import NoOpTracer
agent = mock_chat_agent()
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await agent.run("Test message")
assert response is not None
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_streaming_capture_messages_skipped_when_span_not_recording(
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Agent heavy serialization is skipped when no provider is configured (streaming)."""
from opentelemetry.trace import NoOpTracer
agent = mock_chat_agent()
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
updates: list[Any] = []
stream = agent.run("Test message", stream=True)
async for update in stream:
updates.append(update)
await stream.get_final_response()
assert len(updates) == 2
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_capture_messages_called_when_span_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Sanity check: with a real recording provider, sensitive-data capture still runs."""
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await client.get_response(messages=messages, options={"model": "Test"})
assert response is not None
# Two _capture_messages calls: one for input, one for output messages.
assert mock_capture_messages.call_count == 2
assert mock_capture_response.call_count == 1
@@ -793,8 +793,22 @@ class RawFoundryAgent( # type: ignore[misc]
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from agent_framework.observability import (
OBSERVABILITY_SETTINGS,
create_metric_views,
create_resource,
enable_instrumentation,
)
from azure.core.exceptions import ResourceNotFoundError
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"FoundryAgent.configure_azure_monitor(): Skipping setup because instrumentation was "
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
"to re-enable, then re-invoke configure_azure_monitor()."
)
return
client = self.client
if not isinstance(client, RawFoundryAgentChatClient):
raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.")
@@ -817,8 +831,6 @@ class RawFoundryAgent( # type: ignore[misc]
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
@@ -271,8 +271,22 @@ class RawFoundryChatClient( # type: ignore[misc]
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from agent_framework.observability import (
OBSERVABILITY_SETTINGS,
create_metric_views,
create_resource,
enable_instrumentation,
)
from azure.core.exceptions import ResourceNotFoundError
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"FoundryChatClient.configure_azure_monitor(): Skipping setup because instrumentation was "
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
"to re-enable, then re-invoke configure_azure_monitor()."
)
return
try:
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
except ResourceNotFoundError:
@@ -291,8 +305,6 @@ class RawFoundryChatClient( # type: ignore[misc]
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
@@ -12,6 +12,7 @@ import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from typing import Protocol, cast
from agent_framework import (
@@ -25,12 +26,14 @@ from agent_framework import (
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework.exceptions import AgentFrameworkException
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
)
from azure.ai.agentserver.responses._id_generator import IdGenerator
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ApplyPatchToolCallItemParam,
@@ -108,11 +111,13 @@ from azure.ai.agentserver.responses.streaming._builders import (
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from mcp import McpError
from typing_extensions import Any
logger = logging.getLogger(__name__)
# region Approval Storage
class ApprovalStorage(Protocol):
"""Storage for saving function approval requests."""
@@ -247,6 +252,39 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
return FileCheckpointStorage(storage_path)
# endregion Approval Storage
# Foundry Toolbox Auth integration
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
CONSENT_ERROR_CODE = -32007
def consent_url_from_error(exc: BaseException) -> str | None:
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
Args:
exc: The exception to inspect.
Returns:
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
"""
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
return inner_exception.error.message
return None
# endregion Foundry Toolbox Auth integration
# region ResponsesHostServer
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
@@ -315,8 +353,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
# Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on
# the first request rather than at server startup, so that authentication
# failures during MCP connect can be surfaced to the client as an
# `oauth_consent_request` stream event instead of crashing the server.
self._agent_stack: AsyncExitStack | None = None
self._agent_init_lock = asyncio.Lock()
self.shutdown_handler(self._cleanup_agent) # pyright: ignore[reportUnknownMemberType]
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
async def _ensure_agent_ready(self) -> None:
"""Lazily enter the agent's async context exactly once.
On failure the partial exit stack is closed and ``_agent_stack`` is left
as ``None`` so a subsequent request (e.g. after the user completes OAuth
consent) can retry the connection.
"""
if self._agent_stack is not None:
return
async with self._agent_init_lock:
if self._agent_stack is not None:
return
stack = AsyncExitStack()
try:
if isinstance(self._agent, AbstractAsyncContextManager):
await stack.enter_async_context(self._agent)
except BaseException:
await stack.aclose()
raise
self._agent_stack = stack
async def _cleanup_agent(self) -> None:
"""Close the agent's async context. Registered as the server shutdown handler."""
stack = self._agent_stack
if stack is not None:
self._agent_stack = None
await stack.aclose()
async def _handle_response(
self,
request: CreateResponse,
@@ -359,45 +432,76 @@ class ResponsesHostServer(ResponsesAgentServerHost):
else:
run_kwargs["options"] = chat_options
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
# Lazy-enter the agent (and any MCP tools it owns). The MCP client wraps gateway
# consent failures (and other connection-time errors) in AgentFrameworkException; if
# one of those is a consent error we surface the consent link to the client through
# the already-opened response stream instead of crashing the request. Other exception
# types propagate normally so the host can handle / log them.
try:
await self._ensure_agent_ready()
except AgentFrameworkException as ex:
consent_url = consent_url_from_error(ex)
if consent_url is None:
raise
logger.warning("OAuth consent required for Foundry MCP gateway.")
oauth_item = OAuthConsentRequestOutputItem(
id=IdGenerator.new_id("oacr"),
consent_link=consent_url,
server_label="Foundry Toolbox",
)
builder = response_event_stream.add_output_item(oauth_item.id)
yield builder.emit_added(oauth_item)
yield builder.emit_done(oauth_item)
yield response_event_stream.emit_completed()
return
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
tracker: _OutputItemTracker | None = _OutputItemTracker(response_event_stream) if is_streaming_request else None
# Run the agent in streaming mode
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
for content in update.contents:
for event in tracker.handle(content):
try:
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
yield response_event_stream.emit_completed()
else:
if tracker is None: # pragma: no cover - defensive, set above
raise RuntimeError("Streaming tracker was not initialized.")
# Run the agent in streaming mode
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
yield response_event_stream.emit_completed()
except Exception:
# Drain any in-progress streaming builder before emitting consent
# so the resulting stream stays well-formed.
if tracker is not None:
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
raise
async def _handle_inner_workflow(
self,
@@ -429,6 +533,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Workflow agents are not async context managers in any built-in path,
# but call _ensure_agent_ready for symmetry with the regular path so
# any future async resources owned by the workflow are entered here.
await self._ensure_agent_ready()
# Determine the latest checkpoint (if any) so we can resume the
# workflow's prior state for this turn. The directory is keyed by
# the inbound context id (conversation_id when set, otherwise
@@ -551,6 +660,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
await checkpoint_storage.delete(checkpoint.checkpoint_id)
# endregion ResponsesHostServer
# region Active Builder State
@@ -27,14 +27,18 @@ from agent_framework import (
ResponseStream,
)
from azure.ai.agentserver.responses import InMemoryResponseProvider
from mcp import McpError
from mcp.types import ErrorData
from typing_extensions import Any
from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import (
CONSENT_ERROR_CODE,
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
_item_to_message, # pyright: ignore[reportPrivateUsage]
_output_item_to_message, # pyright: ignore[reportPrivateUsage]
consent_url_from_error,
)
@@ -2888,6 +2892,187 @@ class TestCheckpointContextPathValidation:
f"before={before} after={after}"
)
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
"""Build an exception wrapping a Foundry MCP gateway consent error.
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
which catches connection-time ``McpError``s and re-raises them as a
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
original error attached via ``inner_exception``. ``consent_url_from_error``
then finds the wrapped ``McpError`` in ``exc.args``.
"""
from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
return ToolExecutionException("MCP consent required", inner_exception=inner)
class TestConsentUrlFromError:
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
exc = _make_consent_error("https://example.com/consent")
assert consent_url_from_error(exc) == "https://example.com/consent"
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
assert consent_url_from_error(Exception("boom")) is None
def test_returns_none_when_mcp_error_has_different_code(self) -> None:
inner = McpError(ErrorData(code=-32000, message="some other error"))
exc = Exception("wrapped", inner)
assert consent_url_from_error(exc) is None
def test_returns_none_for_bare_mcp_error_without_wrapping(self) -> None:
# `args` of a bare McpError holds the message string, not an McpError
# instance, so it does not match the wrapping pattern produced by the
# MCP client when it bubbles consent errors up.
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
assert consent_url_from_error(bare) is None
class TestAgentLifecycle:
async def test_agent_entered_lazily_on_first_request(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
# Construction must not enter the agent.
assert agent.__aenter__.await_count == 0
await _post(server, input_text="hello", stream=False)
assert agent.__aenter__.await_count == 1
async def test_agent_entered_only_once_across_requests(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
await _post(server, input_text="first", stream=False)
await _post(server, input_text="second", stream=False)
await _post(server, input_text="third", stream=False)
assert agent.__aenter__.await_count == 1
async def test_cleanup_exits_agent_and_allows_reentry(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
await _post(server, input_text="hello", stream=False)
assert agent.__aenter__.await_count == 1
assert agent.__aexit__.await_count == 0
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
assert agent.__aexit__.await_count == 1
# Cleanup is idempotent.
await server._cleanup_agent() # pyright: ignore[reportPrivateUsage]
assert agent.__aexit__.await_count == 1
# After cleanup, a follow-up request re-enters the agent.
await _post(server, input_text="again", stream=False)
assert agent.__aenter__.await_count == 2
async def test_failed_entry_does_not_cache_stack(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = [_make_consent_error(), None]
server = _make_server(agent)
await _post(server, input_text="first", stream=False)
# Failed entry must leave the stack empty so the next request retries.
await _post(server, input_text="second", stream=False)
assert agent.__aenter__.await_count == 2
class TestOAuthConsentSurfacing:
async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"]
assert len(oauth_items) == 1
assert oauth_items[0]["consent_link"] == "https://consent.example.com/auth"
assert oauth_items[0]["server_label"] == "Foundry Toolbox"
# The agent must not be run when entry fails.
agent.run.assert_not_called()
async def test_streaming_consent_error_emits_oauth_output_item(self) -> None:
agent = _make_agent(stream_updates=[AgentResponseUpdate(contents=[Content.from_text("hi")], role="assistant")])
agent.__aenter__.side_effect = _make_consent_error("https://consent.example.com/auth")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
added = [e for e in events if e["event"] == "response.output_item.added"]
oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"]
assert len(oauth_added) == 1
assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/auth"
assert oauth_added[0]["data"]["item"]["server_label"] == "Foundry Toolbox"
done = [e for e in events if e["event"] == "response.output_item.done"]
assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done)
agent.run.assert_not_called()
async def test_non_consent_error_during_entry_propagates(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.__aenter__.side_effect = RuntimeError("boom")
server = _make_server(agent)
resp = await _post(server, input_text="hello", stream=False)
# Non-consent errors are not swallowed: the response is marked failed
# and no `oauth_consent_request` item is emitted.
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "failed"
assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", []))
agent.run.assert_not_called()
async def test_retry_after_consent_succeeds(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hello!")])])
)
agent.__aenter__.side_effect = [_make_consent_error("https://consent.example.com/auth"), None]
server = _make_server(agent)
# First request surfaces consent; agent.run is not called.
resp1 = await _post(server, input_text="first", stream=False)
assert resp1.status_code == 200
body1 = resp1.json()
oauth = [it for it in body1["output"] if it["type"] == "oauth_consent_request"]
assert len(oauth) == 1
agent.run.assert_not_called()
# After the user authenticates, the next request enters successfully.
resp2 = await _post(server, input_text="second", stream=False)
assert resp2.status_code == 200
body2 = resp2.json()
assert body2["status"] == "completed"
assert any(it["type"] == "message" for it in body2["output"])
assert agent.__aenter__.await_count == 2
agent.run.assert_awaited_once()
# endregion
+78
View File
@@ -0,0 +1,78 @@
# Monty Package (agent-framework-monty)
Monty-backed CodeAct integrations for the Microsoft Agent Framework.
> [!NOTE]
> **Alpha package.** Not part of `agent-framework[all]` yet. Install explicitly
> with `pip install agent-framework-monty --pre`.
## Core Classes
- **`MontyCodeActProvider`** — `ContextProvider` that injects a run-scoped
`execute_code` tool plus dynamic CodeAct instructions. Mirrors the
`HyperlightCodeActProvider` API for the parts that apply to a non-sandboxed
Python interpreter.
- **`MontyExecuteCodeTool`** — `FunctionTool` that wraps the Monty interpreter.
Use directly for mixed-tool agents or manual static wiring. Mirrors
`HyperlightExecuteCodeTool`.
## Public API
```python
from agent_framework_monty import (
FileMount,
FileMountInput,
MontyCodeActProvider,
MontyExecuteCodeTool,
MountMode,
)
```
`MontyCodeActProvider` and `MontyExecuteCodeTool` both accept:
- `tools` — host tool callables / `FunctionTool`s
- `approval_mode` — `"never_require"` (default) or `"always_require"`
- `workspace_root` — host directory auto-mounted at `/input`
(mirrors `HyperlightCodeActProvider.workspace_root`)
- `file_mounts` — sequence of `FileMountInput` (str shorthand,
`(host_path, mount_path)` tuple, or `FileMount`)
- `resource_limits` — Monty `ResourceLimits` TypedDict
Tool-management methods on both classes: `add_tools`, `get_tools`,
`remove_tool`, `clear_tools`. Mount-management methods: `add_file_mounts`,
`get_file_mounts`, `remove_file_mount`, `clear_file_mounts`.
`MontyExecuteCodeTool` additionally exposes:
- `build_instructions(*, tools_visible_to_model: bool) -> str`
- `create_run_tool() -> MontyExecuteCodeTool`
- `build_serializable_state() -> dict[str, Any]`
- `workspace_root`, `resource_limits` properties
## Architecture
- **`_types.py`** — `FileMount`, `FileMountInput`, `MountMode` (public).
- **`_provider.py`** — `MontyCodeActProvider` (thin wrapper around the tool).
- **`_execute_code_tool.py`** — `MontyExecuteCodeTool` plus tool / mount
normalization, approval helpers, dynamic `description`/`instructions`
builders, and the post-execution file-capture flow that surfaces files
written to `read-write` mounts as `Content.from_data` items.
- **`_monty_bridge.py`** — `InlineCodeBridge` and `generate_type_stubs`,
adapted from the reference Monty CodeAct repo. Pauses on `FunctionSnapshot`
to dispatch host calls, then resumes; supports direct typed tool calls,
the `call_tool` fallback, `asyncio.gather` fan-out, and forwards
``mount`` / ``limits`` to `Monty(...).start(...)`.
- **`_instructions.py`** — dynamic instruction / tool-description builders
(include filesystem capability summaries when mounts are configured).
## Not implemented (yet)
| Capability | Monty primitive | Status |
|------------|-----------------|--------|
| Custom virtual filesystem | `OSAccess` subclass passed to `Monty(...).start(os=...)` | Not exposed. Strictly more general than file mounts; useful when you want a fully synthetic FS. |
| Outbound URL allow-list | No Monty primitive — expose `fetch_url` as a host tool with the allow-list check in your tool function. | Not exposed in this package; users add it as a regular tool. |
## Out of scope (for now)
- **Durable execution** — the reference Monty CodeAct repo also offers a
Durable-Functions-backed mode (`DurableCodeBridge`, `register_durable_codeact`,
`wait_for_external_event`, per-tool approval via external events). That is
intentionally not in this package yet.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+179
View File
@@ -0,0 +1,179 @@
# agent-framework-monty
Monty-backed CodeAct integrations for Microsoft Agent Framework.
> [!WARNING]
> This package is in **alpha**. APIs may change without notice. It is not part of
> `agent-framework[all]` yet; install it explicitly with `--pre`.
## Installation
```bash
pip install agent-framework-monty --pre
```
The package depends on [`pydantic-monty`](https://github.com/pydantic/monty), a
Rust-based Python interpreter, so it runs on Linux, macOS, and Windows wherever
Monty wheels are published — no hypervisor or WASM backend required.
## Quick start
### Context provider (recommended)
Use `MontyCodeActProvider` to automatically inject the `execute_code` tool and
CodeAct instructions into every agent run. Tools registered on the provider are
available inside the Monty interpreter as **typed async functions** (e.g.
`await compute(operation="add", a=1, b=2)`), and as a fallback through
`call_tool(...)`.
```python
from agent_framework import Agent, tool
from agent_framework_monty import MontyCodeActProvider
@tool
def compute(operation: str, a: float, b: float) -> float:
"""Perform a math operation."""
ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b}
return ops[operation]
codeact = MontyCodeActProvider(
tools=[compute],
approval_mode="never_require",
)
agent = Agent(
client=client,
name="CodeActAgent",
instructions="You are a helpful assistant.",
context_providers=[codeact],
)
result = await agent.run("Multiply 6 by 7 using execute_code.")
```
### Standalone tool
Use `MontyExecuteCodeTool` directly when you want full control over how the
tool is added to the agent (e.g. when mixing sandbox tools with direct-only
tools on the same agent).
```python
from agent_framework import Agent, tool
from agent_framework_monty import MontyExecuteCodeTool
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email (direct-only, not available inside the sandbox)."""
return f"Email sent to {to}"
execute_code = MontyExecuteCodeTool(
tools=[compute],
approval_mode="never_require",
)
agent = Agent(
client=client,
name="MixedToolsAgent",
instructions="You are a helpful assistant.",
tools=[send_email, execute_code],
)
```
### Manual static wiring
For fixed configurations where provider lifecycle overhead is unnecessary,
build the CodeAct instructions once and pass them to the agent at construction
time:
```python
execute_code = MontyExecuteCodeTool(
tools=[compute],
approval_mode="never_require",
)
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
agent = Agent(
client=client,
name="StaticWiringAgent",
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
tools=[execute_code],
)
```
### File mounts and resource limits
Mount host directories into the sandbox and cap execution resources:
```python
from agent_framework_monty import FileMount, MontyCodeActProvider
codeact = MontyCodeActProvider(
tools=[compute],
workspace_root="/host/workspace", # auto-mounted at /input (read-write)
file_mounts=[
"/host/data", # shorthand: same path on both sides
("/host/models", "/sandbox/models"), # explicit (host, mount_path)
FileMount( # full control
host_path="/host/cache",
mount_path="/sandbox/cache",
mode="overlay", # "read-only" | "read-write" | "overlay"
write_bytes_limit=10 * 1024 * 1024,
),
],
resource_limits={ # Monty ResourceLimits TypedDict
"max_duration_secs": 5.0,
"max_memory": 64 * 1024 * 1024,
},
)
```
- **`workspace_root`** mirrors the Hyperlight default: the directory is mounted
at `/input` in `read-write` mode.
- **`file_mounts`** accepts a string shorthand, a `(host_path, mount_path)`
tuple, or a `FileMount` named tuple (with optional `mode` and
`write_bytes_limit`).
- Files written by the sandbox to any **`read-write`** mount are scanned
after each `execute_code` call and returned as `Content.from_data(...)`
attachments (with a `path` annotation in `additional_properties`),
mirroring Hyperlight's `/output` flow.
- `overlay` mounts buffer writes in memory (nothing leaks to the host and
nothing is captured). `read-only` mounts reject writes.
- **`resource_limits`** is forwarded straight to Monty's
[`ResourceLimits`](https://github.com/pydantic/monty) TypedDict
(`max_allocations`, `max_duration_secs`, `max_memory`, `gc_interval`,
`max_recursion_depth`).
## DSL inside `execute_code`
The model generates Python code that runs inside Monty's Rust-based interpreter.
Available primitives:
| Primitive | Behavior |
|-----------|----------|
| `await tool_name(**kwargs)` | Direct typed call to a registered host tool. Argument types are checked before execution. |
| `await call_tool("name", **kwargs)` | Generic fallback that dispatches by tool name. Not type-checked. |
| `asyncio.gather(...)` | Fans out concurrent tool calls. |
| `print(...)` | Captured and surfaced as text in the tool result. |
## Notes
- `MontyCodeActProvider` and `MontyExecuteCodeTool` mirror the API surface of
the `agent-framework-hyperlight` counterparts where the underlying runtime
supports it.
- Monty interprets a **subset** of Python (a Rust-based interpreter). Most
control flow, common stdlib modules (`sys`, `os`, `typing`, `asyncio`, `re`,
`datetime`, `json`), and async functions are supported, but exotic features
may not be available. OS-level access (filesystem, network, subprocess) is
rejected with `PermissionError` **by default**; mount host directories with
`workspace_root` / `file_mounts` to grant scoped filesystem access.
- Code is type-checked against tool signatures via
[ty](https://docs.astral.sh/ty/) before execution, so wrong argument types
surface as a clear error before any host tool runs.
- The alpha package is **not** part of `agent-framework[all]` yet, so it must
be installed explicitly. Once promoted to beta it will be reachable via the
lazy-loading namespace `agent_framework.monty`.
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import importlib.metadata
from ._execute_code_tool import MontyExecuteCodeTool
from ._provider import MontyCodeActProvider
from ._types import FileMount, FileMountInput, MountMode
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"FileMount",
"FileMountInput",
"MontyCodeActProvider",
"MontyExecuteCodeTool",
"MountMode",
"__version__",
]
@@ -0,0 +1,558 @@
# Copyright (c) Microsoft. All rights reserved.
"""``MontyExecuteCodeTool`` - a ``FunctionTool`` that runs Python in Monty.
Mirrors the public API of ``HyperlightExecuteCodeTool`` for the subset that
applies to a pure-Python interpreter (no backends to choose from). By default
the Monty sandbox rejects OS / filesystem / network calls with
``PermissionError``; pass ``workspace_root`` or ``file_mounts`` to expose
scoped host directories, and the tool will capture any files written under
``read-write`` mounts as ``Content`` items in the response.
"""
from __future__ import annotations
import json
import mimetypes
from collections.abc import Callable, Iterator, Sequence
from copy import copy
from functools import partial
from pathlib import Path, PurePosixPath
from typing import Any, cast
from agent_framework import Content, FunctionTool
from agent_framework._tools import ApprovalMode, normalize_tools
from ._instructions import build_codeact_instructions, build_execute_code_description
from ._monty_bridge import InlineCodeBridge, generate_type_stubs
from ._types import FileMount, FileMountInput
EXECUTE_CODE_TOOL_NAME = "execute_code"
EXECUTE_CODE_TOOL_DESCRIPTION = "Execute Python in a Monty interpreter."
#: Virtual path that the optional ``workspace_root`` directory is mounted at,
#: matching the Hyperlight default. Use ``file_mounts`` for any other path.
WORKSPACE_MOUNT_PATH = "/input"
#: Maximum bytes per captured output file. Files larger than this are skipped
#: and a ``Content.from_text`` warning is appended in their place.
MAX_CAPTURED_FILE_BYTES = 5 * 1024 * 1024 # 5 MiB
EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "_ExecuteCodeInput",
"properties": {
"code": {
"type": "string",
"title": "Code",
"description": "Python code to execute in a Monty interpreter.",
},
},
"required": ["code"],
}
def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
"""Merge tool groups, dropping any ``execute_code`` entries and deduping by name."""
tools_by_name: dict[str, FunctionTool] = {}
for tool_group in tool_groups:
normalized_group = normalize_tools(tool_group)
for tool_obj in normalized_group:
if not isinstance(tool_obj, FunctionTool):
continue
if tool_obj.name == EXECUTE_CODE_TOOL_NAME:
continue
tools_by_name.pop(tool_obj.name, None)
tools_by_name[tool_obj.name] = tool_obj
return list(tools_by_name.values())
def _resolve_execute_code_approval_mode(
*,
base_approval_mode: ApprovalMode,
tools: Sequence[FunctionTool],
) -> ApprovalMode:
if base_approval_mode == "always_require":
return "always_require"
if any(tool_obj.approval_mode == "always_require" for tool_obj in tools):
return "always_require"
return "never_require"
def _normalize_mount_path(mount_path: str) -> str:
"""Normalize a virtual mount path to a clean POSIX absolute path."""
raw = mount_path.strip().replace("\\", "/")
if not raw:
raise ValueError("mount_path must not be empty.")
pure = PurePosixPath(raw)
parts = [part for part in pure.parts if part not in {"", "/", "."}]
if any(part == ".." for part in parts):
raise ValueError("mount_path must not contain '..' segments.")
if not parts:
raise ValueError("mount_path must point to a concrete absolute path.")
return "/" + "/".join(parts)
def _resolve_existing_directory(value: str | Path) -> Path:
resolved = Path(value).expanduser().resolve(strict=True)
if not resolved.is_dir():
raise ValueError(f"Path {value!r} must point to an existing directory.")
return resolved
def _is_file_mount_pair(value: Any) -> bool:
if not isinstance(value, tuple) or isinstance(value, FileMount):
return False
items = cast("tuple[object, ...]", value)
if len(items) != 2:
return False
host_path, mount_path = items
return isinstance(host_path, (str, Path)) and isinstance(mount_path, str)
def _normalize_file_mount(file_mount: FileMountInput) -> FileMount:
if isinstance(file_mount, FileMount):
host_path = file_mount.host_path
mount_path = file_mount.mount_path
mode = file_mount.mode
write_limit = file_mount.write_bytes_limit
elif isinstance(file_mount, str):
host_path = file_mount
mount_path = file_mount
mode = "overlay"
write_limit = None
else:
host_path, mount_path = file_mount
mode = "overlay"
write_limit = None
return FileMount(
host_path=_resolve_existing_directory(host_path),
mount_path=_normalize_mount_path(mount_path),
mode=mode,
write_bytes_limit=write_limit,
)
def _to_monty_mount(file_mount: FileMount) -> Any:
"""Convert a public :class:`FileMount` to Monty's ``MountDir``.
Imports lazily through the bridge's loader so missing-dependency errors
surface as the same actionable ``RuntimeError`` the rest of the package
raises, rather than a bare ``ImportError`` from a top-level import.
"""
from ._monty_bridge import load_monty # avoid top-level pydantic_monty import
monty_module = load_monty()
return monty_module.MountDir(
virtual_path=file_mount.mount_path,
host_path=str(file_mount.host_path),
mode=file_mount.mode,
write_bytes_limit=file_mount.write_bytes_limit,
)
def _make_tool_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
"""Return an async callable that invokes ``tool_obj`` with the bridge's kwargs.
Returns the raw native value (no ``Content`` wrapping) so the Monty interpreter
receives real Python objects. ``FunctionTool.invoke`` accepts direct keyword
arguments and handles both sync and async underlying functions internally.
"""
return partial(copy(tool_obj).invoke, skip_parsing=True)
class MontyExecuteCodeTool(FunctionTool):
"""Execute Python code inside a Monty interpreter.
Tools registered on this object are available inside the interpreter as
typed async functions (e.g. ``await tool_name(...)``). Argument types are
validated by the [ty](https://docs.astral.sh/ty/) type checker before any
host tool runs.
Optional filesystem access is exposed via:
- ``workspace_root`` — auto-mounts a host directory at ``/input`` (matching
Hyperlight's default).
- ``file_mounts`` — extra :class:`FileMount` entries for fine-grained
control (mount path, read-only / read-write / overlay mode, write
byte caps).
Files written by sandboxed code to any **read-write** mount are scanned
after execution and returned as ``Content.from_data`` items, mirroring
Hyperlight's ``/output`` flow.
``resource_limits`` is forwarded to Monty's ``ResourceLimits`` to cap CPU
time, memory, output size, recursion depth, and GC frequency.
All mutators (``add_tools``, ``add_file_mounts`` etc.) must be called from
the same task/thread that owns the tool. Monty itself runs on the event
loop, so no internal locking is needed.
"""
def __init__(
self,
*,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
approval_mode: ApprovalMode | None = None,
workspace_root: str | Path | None = None,
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
resource_limits: dict[str, Any] | None = None,
) -> None:
super().__init__(
name=EXECUTE_CODE_TOOL_NAME,
description=EXECUTE_CODE_TOOL_DESCRIPTION,
approval_mode="never_require",
func=self._run_code,
input_model=EXECUTE_CODE_INPUT_SCHEMA,
)
self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
self._managed_tools: list[FunctionTool] = []
self._workspace_root: Path | None = (
_resolve_existing_directory(workspace_root) if workspace_root is not None else None
)
self._file_mounts: dict[str, FileMount] = {}
self._resource_limits: dict[str, Any] | None = dict(resource_limits) if resource_limits else None
if tools is not None:
self.add_tools(tools)
if file_mounts is not None:
self.add_file_mounts(file_mounts)
self._refresh_approval_mode()
@property
def description(self) -> str:
# During FunctionTool.__init__, ``_managed_tools`` is not yet set.
if not hasattr(self, "_managed_tools"):
return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION))
return build_execute_code_description(
tools=self._managed_tools,
mounts=self._effective_mounts(),
)
@description.setter
def description(self, value: str) -> None:
self.__dict__["description"] = value
def add_tools(
self,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
) -> None:
"""Add Monty-side tools to this execute_code surface."""
self._managed_tools = _collect_tools(self._managed_tools, tools)
self._refresh_approval_mode()
def get_tools(self) -> list[FunctionTool]:
"""Return the currently managed Monty tools."""
return list(self._managed_tools)
def remove_tool(self, name: str) -> None:
"""Remove one managed Monty tool by name."""
remaining_tools = [tool_obj for tool_obj in self._managed_tools if tool_obj.name != name]
if len(remaining_tools) == len(self._managed_tools):
raise KeyError(f"No managed tool named {name!r} is registered.")
self._managed_tools = remaining_tools
self._refresh_approval_mode()
def clear_tools(self) -> None:
"""Remove all managed Monty tools."""
self._managed_tools = []
self._refresh_approval_mode()
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
"""Add one or more file mounts.
A single string mounts the same path on both sides. Use a
``(host_path, mount_path)`` tuple or :class:`FileMount` when the paths
differ or when you need to set the mount mode / write limit.
"""
if isinstance(file_mounts, (str, FileMount)) or _is_file_mount_pair(file_mounts):
normalized = [_normalize_file_mount(cast("FileMountInput", file_mounts))]
else:
normalized = [_normalize_file_mount(item) for item in cast("Sequence[FileMountInput]", file_mounts)]
for mount in normalized:
self._file_mounts[mount.mount_path] = mount
def get_file_mounts(self) -> list[FileMount]:
"""Return the configured file mounts (excluding ``workspace_root``)."""
return list(self._file_mounts.values())
def remove_file_mount(self, mount_path: str) -> None:
"""Remove one file mount by its sandbox path."""
normalized = _normalize_mount_path(mount_path)
if normalized not in self._file_mounts:
raise KeyError(f"No file mount exists for {mount_path!r}.")
del self._file_mounts[normalized]
def clear_file_mounts(self) -> None:
"""Remove all configured file mounts."""
self._file_mounts.clear()
@property
def workspace_root(self) -> Path | None:
"""Return the configured workspace root, if any."""
return self._workspace_root
@property
def resource_limits(self) -> dict[str, Any] | None:
"""Return the configured Monty :class:`pydantic_monty.ResourceLimits`, if any."""
return dict(self._resource_limits) if self._resource_limits else None
def build_instructions(self, *, tools_visible_to_model: bool) -> str:
"""Build the current CodeAct instructions for this execute_code surface."""
return build_codeact_instructions(
tools=list(self._managed_tools),
tools_visible_to_model=tools_visible_to_model,
mounts=self._effective_mounts(),
)
def create_run_tool(self) -> MontyExecuteCodeTool:
"""Create a run-scoped snapshot of this execute_code surface."""
return MontyExecuteCodeTool(
tools=self.get_tools(),
approval_mode=self._default_approval_mode,
workspace_root=self._workspace_root,
file_mounts=list(self._file_mounts.values()) or None,
resource_limits=self._resource_limits,
)
def build_serializable_state(self) -> dict[str, Any]:
"""Return a JSON-serializable snapshot of the effective run state."""
approval_mode = _resolve_execute_code_approval_mode(
base_approval_mode=self._default_approval_mode,
tools=self._managed_tools,
)
mounts = self._effective_mounts()
return {
"runtime": "monty",
"approval_mode": approval_mode,
"tool_names": [tool_obj.name for tool_obj in self._managed_tools],
"workspace_root": str(self._workspace_root) if self._workspace_root is not None else None,
"file_mounts": [
{
"host_path": str(mount.host_path),
"mount_path": mount.mount_path,
"mode": mount.mode,
"write_bytes_limit": mount.write_bytes_limit,
}
for mount in mounts
],
"resource_limits": dict(self._resource_limits) if self._resource_limits else None,
}
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
# Materialize the dynamic description so the dump captures the current tool list.
self.__dict__["description"] = self.description
return super().to_dict(exclude=exclude, exclude_none=exclude_none)
def _refresh_approval_mode(self) -> None:
self.approval_mode = _resolve_execute_code_approval_mode(
base_approval_mode=self._default_approval_mode,
tools=self._managed_tools,
)
def _build_tool_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
return {tool_obj.name: _make_tool_callback(tool_obj) for tool_obj in tools}
def _build_type_stub_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]:
"""Return a name -> underlying-Python-callable map for type stub generation.
The raw Python function attached to the ``FunctionTool`` carries the
author's actual ``Annotated`` parameter types, which are what we want
``ty`` to validate against. Tools without an attached function (e.g.
``declaration_only`` tools) are skipped.
"""
stub_map: dict[str, Callable[..., Any]] = {}
for tool_obj in tools:
func = getattr(tool_obj, "func", None)
if callable(func):
stub_map[tool_obj.name] = func
return stub_map
def _effective_mounts(self) -> list[FileMount]:
"""Combine ``workspace_root`` (if set) with the explicit ``file_mounts``."""
mounts: list[FileMount] = []
if self._workspace_root is not None and WORKSPACE_MOUNT_PATH not in self._file_mounts:
mounts.append(
FileMount(
host_path=self._workspace_root,
mount_path=WORKSPACE_MOUNT_PATH,
mode="read-write",
write_bytes_limit=None,
)
)
mounts.extend(self._file_mounts.values())
return mounts
async def _run_code(self, *, code: str) -> list[Content]:
tools = list(self._managed_tools)
mounts = self._effective_mounts()
tool_map = self._build_tool_map(tools)
stub_map = self._build_type_stub_map(tools)
type_stubs = generate_type_stubs(stub_map) if stub_map else None
# Snapshot mtimes of host files in read-write mounts so we can later
# identify which files the sandbox actually touched.
pre_state = _snapshot_writable_mounts(mounts)
bridge = InlineCodeBridge(
tool_map,
type_stubs=type_stubs,
mounts=[_to_monty_mount(mount) for mount in mounts] or None,
resource_limits=self._resource_limits,
)
try:
result = await bridge.run(code)
except Exception as exc:
return [
Content.from_error(
message="Execution error",
error_details=f"{type(exc).__name__}: {exc}",
),
]
contents = _build_execution_contents(result=result)
contents.extend(_capture_written_files(mounts, pre_state))
return contents
def _build_execution_contents(*, result: dict[str, Any]) -> list[Content]:
stdout = str(result.get("stdout") or "").replace("\r\n", "\n")
output_value = result.get("output")
truncated = bool(result.get("truncated"))
outputs: list[Content] = []
if stdout:
text = stdout
if truncated:
text = f"{text}\n\n[stdout truncated]"
outputs.append(Content.from_text(text))
elif truncated:
outputs.append(Content.from_text("[stdout truncated]"))
if output_value is not None:
try:
serialized_output = json.dumps(output_value, ensure_ascii=False)
except (TypeError, ValueError):
serialized_output = repr(output_value)
outputs.append(Content.from_text(serialized_output))
if not outputs:
outputs.append(Content.from_text("Code executed successfully without output."))
return outputs
def _iter_real_files(root: Path) -> Iterator[Path]:
"""Walk ``root`` recursively, yielding only real (non-symlink) files.
``Path.rglob`` follows directory symlinks by default, which combined with
``Path.is_file()`` / ``Path.read_bytes()`` (both follow symlinks) would let
an attacker who controls the workspace pre-place a symlink to a host file
or directory and have our post-execution capture surface it. Skipping every
symlink at both the directory and file level closes that escape.
"""
stack: list[Path] = [root]
while stack:
current = stack.pop()
try:
entries = list(current.iterdir())
except OSError:
continue
for entry in entries:
try:
if entry.is_symlink():
continue
if entry.is_dir():
stack.append(entry)
elif entry.is_file():
yield entry
except OSError:
continue
def _snapshot_writable_mounts(mounts: Sequence[FileMount]) -> dict[str, dict[str, tuple[int, int]]]:
"""Capture (size, mtime_ns) for every real (non-symlink) host file under read-write mounts.
Returns ``{mount_path: {relative_posix_path: (size, mtime_ns)}}``. Used by
:func:`_capture_written_files` to detect new or modified files after the run.
Read-only and overlay mounts are skipped because their writes do not
propagate to the host. Symlinks (file or directory) are deliberately skipped
so an attacker cannot escape the mount by pre-placing a symlink to a host
path outside the workspace.
"""
snapshot: dict[str, dict[str, tuple[int, int]]] = {}
for mount in mounts:
if mount.mode != "read-write":
continue
host_root = Path(mount.host_path)
per_mount: dict[str, tuple[int, int]] = {}
for entry in _iter_real_files(host_root):
try:
stat = entry.lstat() # lstat: never follow symlinks (defensive)
except OSError:
continue
relative = entry.relative_to(host_root).as_posix()
per_mount[relative] = (int(stat.st_size), int(stat.st_mtime_ns))
snapshot[mount.mount_path] = per_mount
return snapshot
def _capture_written_files(
mounts: Sequence[FileMount],
pre_state: dict[str, dict[str, tuple[int, int]]],
) -> list[Content]:
"""Return :class:`Content` items for files the sandbox wrote during the run.
Mirrors Hyperlight's ``/output`` capture flow: any new or modified real
(non-symlink) file under a read-write mount is read back as binary and
surfaced as ``Content.from_data`` with a ``path`` annotation in
``additional_properties``. Symlinks are skipped at both directory and file
level so a malicious workspace cannot trick us into capturing host files
outside the configured mount root.
"""
captured: list[Content] = []
for mount in mounts:
if mount.mode != "read-write":
continue
host_root = Path(mount.host_path)
before = pre_state.get(mount.mount_path, {})
for entry in sorted(_iter_real_files(host_root)):
try:
stat = entry.lstat()
except OSError:
continue
relative = entry.relative_to(host_root).as_posix()
current = (int(stat.st_size), int(stat.st_mtime_ns))
if before.get(relative) == current:
continue # Unchanged.
sandbox_path = f"{mount.mount_path.rstrip('/')}/{relative}"
if stat.st_size > MAX_CAPTURED_FILE_BYTES:
captured.append(
Content.from_text(
f"[file {sandbox_path} omitted: {stat.st_size} bytes "
f"exceeds MAX_CAPTURED_FILE_BYTES={MAX_CAPTURED_FILE_BYTES}]"
)
)
continue
try:
# _iter_real_files already excluded symlinks at every level of
# the walk; reading the file here is safe.
data = entry.read_bytes()
except OSError:
continue
media_type = mimetypes.guess_type(entry.name)[0] or "application/octet-stream"
captured.append(
Content.from_data(
data=data,
media_type=media_type,
additional_properties={"path": sandbox_path},
)
)
return captured
@@ -0,0 +1,125 @@
# Copyright (c) Microsoft. All rights reserved.
"""Dynamic CodeAct instructions and execute_code tool descriptions for Monty."""
from __future__ import annotations
from collections.abc import Sequence
from agent_framework import FunctionTool
from ._types import FileMount
def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str:
if not tools:
return "- No tools are currently registered."
lines: list[str] = []
for tool_obj in tools:
parameters = tool_obj.parameters().get("properties", {})
parameter_names = [name for name in parameters if isinstance(name, str)]
parameter_summary = ", ".join(parameter_names) if parameter_names else "none"
description = str(tool_obj.description or "").strip() or "No description provided."
lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.")
return "\n".join(lines)
def _format_filesystem_capabilities(mounts: Sequence[FileMount]) -> str:
if not mounts:
return (
"Filesystem access is unavailable. OS-level paths raise `PermissionError`. "
"If you need files, ask the agent operator to configure `workspace_root` or `file_mounts`."
)
lines = ["Filesystem access is enabled. Read and write paths via `pathlib.Path(...)` (or `os.path`)."]
lines.append("Configured mounts:")
for mount in mounts:
cap = ""
if mount.write_bytes_limit is not None:
cap = f", write cap {mount.write_bytes_limit} bytes"
lines.append(f"- `{mount.mount_path}` ({mount.mode}{cap})")
writable = [mount for mount in mounts if mount.mode == "read-write"]
if writable:
writable_paths = ", ".join(f"`{m.mount_path}`" for m in writable)
lines.append(
f"Files written to {writable_paths} are returned to the caller as attached files; "
"use these paths for any output artifacts."
)
return "\n".join(lines)
def build_codeact_instructions(
*,
tools: Sequence[FunctionTool],
tools_visible_to_model: bool,
mounts: Sequence[FileMount] = (),
) -> str:
"""Build dynamic CodeAct instructions for the effective Monty tool set."""
tool_summaries = _format_tool_summaries(tools)
filesystem_text = _format_filesystem_capabilities(mounts)
usage_note = (
"Some tools may also appear directly, but prefer `execute_code` whenever you need to combine "
"Python control flow with sandbox tool calls."
if tools_visible_to_model
else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them."
)
return f"""You have one primary tool: `execute_code`.
Inside `execute_code`, call registered tools directly as async functions:
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
Your code is type-checked against the tool signatures below before execution.
`await call_tool('name', **kwargs)` is also supported as a fallback but is not type-checked.
For fan-out, use `asyncio.gather`:
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
Surface results to the caller via `print(...)` (captured and returned as text)
or by ending the code with an expression whose value is JSON-encodable - the
value of the final expression is returned alongside captured stdout.
Filesystem capabilities:
{filesystem_text}
Registered tools:
{tool_summaries}
Prefer a single `execute_code` call per request when possible, combining
multiple tool calls with Python control flow.
{usage_note}
"""
def build_execute_code_description(
*,
tools: Sequence[FunctionTool],
mounts: Sequence[FileMount] = (),
) -> str:
"""Build the dynamic ``execute_code`` tool description for standalone usage."""
tool_summaries = _format_tool_summaries(tools)
filesystem_text = _format_filesystem_capabilities(mounts)
return f"""Execute Python code in a Monty interpreter.
Inside the sandbox, call registered tools directly as typed async functions:
`result = await tool_name(param=value)`. Always use `await` and keyword arguments.
Code is type-checked against tool signatures before execution.
`await call_tool('name', **kwargs)` is also supported as a fallback.
For fan-out, use `asyncio.gather`:
`results = await asyncio.gather(tool_a(...), tool_b(...))`.
Filesystem capabilities:
{filesystem_text}
Registered tools:
{tool_summaries}
Surface results via `print(...)` (captured and returned as text) or by ending
with an expression whose value is JSON-encodable.
"""
@@ -0,0 +1,327 @@
# Copyright (c) Microsoft. All rights reserved.
"""Inline (non-durable) Monty execution bridge and type-stub generation.
Adapted from https://github.com/anthonychu/maf-codeact-monty-python.
"""
from __future__ import annotations
import asyncio
import inspect
import keyword
import types
import typing
from collections.abc import Callable, Sequence
from typing import Annotated, Any, cast, get_type_hints
MAX_PRINT_OUTPUT_CHARS = 8192
# Prelude injected into all Monty code so `asyncio.gather` works for fan-out.
_CODEACT_PRELUDE = """\
import asyncio
"""
def _ensure_json_value(value: Any) -> Any:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
raise ValueError("Non-finite floating point values are not JSON-safe.")
return value
if isinstance(value, (list, tuple)):
items = cast("list[object] | tuple[object, ...]", value)
return [_ensure_json_value(item) for item in items]
if isinstance(value, dict):
as_dict = cast("dict[object, object]", value)
return {str(k): _ensure_json_value(v) for k, v in as_dict.items()}
raise ValueError(f"Value of type {type(value).__name__} is not JSON-safe.")
def _external_error(exc: Exception) -> dict[str, str]:
return {"exc_type": type(exc).__name__, "message": str(exc)}
def _parse_call_tool(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
if not args:
raise ValueError("call_tool requires a tool name as the first argument.")
name = args[0]
if not isinstance(name, str) or not name:
raise ValueError("Tool name must be a non-empty string.")
if len(args) > 1:
raise ValueError(
"call_tool accepts only the tool name as a positional argument. Use keyword arguments for parameters."
)
return name, dict(kwargs)
def _build_code(code: str) -> str:
return f"{_CODEACT_PRELUDE}\n{code}"
def _python_type_repr(annotation: Any) -> str:
"""Convert a Python type annotation to its string representation for stubs."""
if annotation is inspect.Parameter.empty:
return "Any"
if annotation is type(None):
# ``None`` in annotations represents ``NoneType``; emit it literally so
# ``ty`` can validate ``Optional[X]`` / ``Union[..., None]`` / ``-> None``
# signatures correctly.
return "None"
origin = typing.get_origin(annotation)
if origin is Annotated:
args = typing.get_args(annotation)
return _python_type_repr(args[0]) if args else "Any"
if origin is not None:
args = typing.get_args(annotation)
# Normalize ``typing.Union[...]`` and PEP-604 ``X | Y`` to PEP-604 syntax so
# ``None`` is preserved across both forms.
if origin is typing.Union or origin is types.UnionType:
return " | ".join(_python_type_repr(a) for a in args) if args else "Any"
origin_name = getattr(origin, "__name__", None)
if origin_name is None:
origin_name = str(origin)
if origin_name.startswith("<class '"):
origin_name = origin_name[8:-2]
if args:
arg_strs = ", ".join(_python_type_repr(a) for a in args)
return f"{origin_name}[{arg_strs}]"
return origin_name
if hasattr(annotation, "__name__"):
return str(annotation.__name__)
return str(annotation)
def generate_type_stubs(tool_callables: dict[str, Callable[..., Any]]) -> str:
"""Generate Python type stub declarations for tools + DSL primitives.
Stubs are fed to Monty's ``type_check_stubs`` so ``ty`` can validate the
LLM-generated code against the actual tool signatures before any host
call runs.
Tools whose ``name`` is not a valid Python identifier are skipped because
their name cannot be safely splatted into stub source. The model can still
reach them via the ``call_tool("weird name", ...)`` fallback at runtime,
but they will not get type-checked stubs.
"""
lines: list[str] = [
"from typing import Any",
"",
"# DSL primitives",
"async def call_tool(name: str, **kwargs: Any) -> Any:",
" raise NotImplementedError()",
"",
"# Registered tools - call directly with typed arguments",
]
for name, func in sorted(tool_callables.items()):
if not name.isidentifier() or keyword.iskeyword(name):
# A non-identifier name (or a Python keyword) would inject invalid
# / dangerous syntax into the stub source. Skip stub generation;
# the tool stays reachable through ``call_tool(name, ...)``.
continue
try:
sig = inspect.signature(func)
hints = get_type_hints(func, include_extras=True)
except (ValueError, TypeError):
lines.append(f"async def {name}(**kwargs: Any) -> Any:")
lines.append(" raise NotImplementedError()")
lines.append("")
continue
params: list[str] = []
for param_name, param in sig.parameters.items():
annotation = hints.get(param_name, inspect.Parameter.empty)
type_str = _python_type_repr(annotation)
if param.default is not inspect.Parameter.empty:
params.append(f"{param_name}: {type_str} = ...")
else:
params.append(f"{param_name}: {type_str}")
return_annotation = hints.get("return", inspect.Parameter.empty)
return_str = _python_type_repr(return_annotation)
param_str = ", ".join(params)
lines.append(f"async def {name}({param_str}) -> {return_str}:")
lines.append(" raise NotImplementedError()")
lines.append("")
return "\n".join(lines)
class _PrintCollector:
"""Collect Monty stdout, capped at ``MAX_PRINT_OUTPUT_CHARS``."""
def __init__(self) -> None:
self.chunks: list[str] = []
self.truncated: bool = False
self._size: int = 0 # running character count to avoid O(n) per append
def __call__(self, stream: str, text: str) -> None:
if self.truncated:
return
remaining = MAX_PRINT_OUTPUT_CHARS - self._size
if remaining <= 0:
self.truncated = True
return
text_value = str(text)
if len(text_value) > remaining:
clipped = text_value[:remaining]
self.chunks.append(clipped)
self._size += len(clipped)
self.truncated = True
else:
self.chunks.append(text_value)
self._size += len(text_value)
@property
def output(self) -> str:
return "".join(self.chunks)
def load_monty() -> Any:
"""Import ``pydantic_monty`` lazily so unit tests can run without it.
Returns the module so callers can read ``Monty``, ``MontyComplete``,
``FunctionSnapshot``, ``FutureSnapshot``, ``NameLookupSnapshot`` from it.
"""
try:
import pydantic_monty # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError(
"The `pydantic-monty` package is required to execute Monty CodeAct code. "
"Install it with `pip install pydantic-monty`."
) from exc
return pydantic_monty
class InlineCodeBridge:
"""Execute Monty code inline (non-durable).
Supports both ``await call_tool('name', ...)`` and direct ``await name(...)``
calls. When Monty yields a :class:`FutureSnapshot`, the bridge invokes the
registered host tools and resumes execution with the results.
"""
def __init__(
self,
tool_map: dict[str, Callable[..., Any]],
*,
type_stubs: str | None = None,
mounts: Sequence[Any] | None = None,
resource_limits: dict[str, Any] | None = None,
) -> None:
self.tool_map: dict[str, Callable[..., Any]] = dict(tool_map)
self.type_stubs: str | None = type_stubs
self._mounts = tuple(mounts) if mounts else ()
self._resource_limits = resource_limits
self._pending_calls: dict[int, tuple[str, dict[str, Any]]] = {}
async def run(self, code: str) -> dict[str, Any]:
if not isinstance(code, str) or not code.strip():
raise ValueError("Code must be a non-empty string.")
monty_module = load_monty()
Monty = monty_module.Monty
MontyComplete = monty_module.MontyComplete
FunctionSnapshot = monty_module.FunctionSnapshot
FutureSnapshot = monty_module.FutureSnapshot
NameLookupSnapshot = monty_module.NameLookupSnapshot
printer = _PrintCollector()
monty = Monty(
_build_code(code),
script_name="codeact.py",
type_check=self.type_stubs is not None,
type_check_stubs=self.type_stubs,
)
start_kwargs: dict[str, Any] = {"print_callback": printer}
if self._mounts:
start_kwargs["mount"] = list(self._mounts)
if self._resource_limits:
start_kwargs["limits"] = self._resource_limits
progress = monty.start(**start_kwargs)
while True:
if isinstance(progress, MontyComplete):
return {
"output": _ensure_json_value(progress.output),
"stdout": printer.output,
"truncated": printer.truncated,
}
if isinstance(progress, FunctionSnapshot):
progress = self._handle_function(progress)
continue
if isinstance(progress, FutureSnapshot):
progress = await self._handle_future(progress)
continue
if isinstance(progress, NameLookupSnapshot):
raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}")
raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}")
def _handle_function(self, snapshot: Any) -> Any:
if snapshot.is_os_function:
return snapshot.resume({
"exc_type": "PermissionError",
"message": "OS and filesystem calls are not available.",
})
function_name = str(snapshot.function_name)
if function_name in self.tool_map:
return self._schedule_direct_tool(snapshot, function_name)
if function_name == "call_tool":
return self._schedule_call_tool(snapshot)
return snapshot.resume({
"exc_type": "NameError",
"message": f"Function {function_name!r} is not available.",
})
def _schedule_direct_tool(self, snapshot: Any, name: str) -> Any:
# Positional args are rejected up-front by ``ty`` because the generated
# stubs declare every parameter as keyword-typed. Anything that slips
# through (e.g. tools with no signature inspection) is forwarded to the
# host tool as-is via kwargs only.
self._pending_calls[int(snapshot.call_id)] = (name, dict(snapshot.kwargs))
return snapshot.resume({"future": ...})
def _schedule_call_tool(self, snapshot: Any) -> Any:
try:
name, kwargs = _parse_call_tool(snapshot.args, snapshot.kwargs)
if name not in self.tool_map:
allowed = ", ".join(sorted(self.tool_map.keys())) or "<none>"
raise ValueError(f"Tool {name!r} is not registered. Available tools: {allowed}")
self._pending_calls[int(snapshot.call_id)] = (name, kwargs)
except Exception as exc:
return snapshot.resume(_external_error(exc))
return snapshot.resume({"future": ...})
async def _handle_future(self, snapshot: Any) -> Any:
pending_call_ids = [int(cid) for cid in snapshot.pending_call_ids]
if not pending_call_ids:
return snapshot.resume({})
entries: list[tuple[int, tuple[str, dict[str, Any]]]] = []
for cid in pending_call_ids:
if cid not in self._pending_calls:
raise RuntimeError(f"Unknown future call ID: {cid}")
entries.append((cid, self._pending_calls.pop(cid)))
tasks = [self._invoke_tool(cid, name, kwargs) for cid, (name, kwargs) in entries]
results = await asyncio.gather(*tasks)
resume_results: dict[int, Any] = dict(results)
return snapshot.resume(resume_results)
async def _invoke_tool(self, cid: int, name: str, kwargs: dict[str, Any]) -> tuple[int, Any]:
# Every entry in ``self.tool_map`` is produced by ``_make_tool_callback``
# as ``partial(FunctionTool.invoke, skip_parsing=True)``. ``FunctionTool.invoke``
# is always ``async def``, so a plain ``await`` is correct for every call and
# avoids relying on ``inspect.iscoroutinefunction(partial(...))``, which can
# return ``False`` for some ``partial`` shapes (cpython#98590) and would route
# the call through ``asyncio.to_thread`` with an unawaited coroutine return.
try:
result = await self.tool_map[name](**kwargs)
return cid, {"return_value": _ensure_json_value(result)}
except Exception as exc:
return cid, _external_error(exc)
@@ -0,0 +1,95 @@
# Copyright (c) Microsoft. All rights reserved.
"""``MontyCodeActProvider`` - context provider injecting Monty-backed CodeAct."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext
from agent_framework._tools import ApprovalMode
from ._execute_code_tool import MontyExecuteCodeTool
from ._types import FileMount, FileMountInput
class MontyCodeActProvider(ContextProvider):
"""Inject a Monty-backed CodeAct surface using provider-owned tools.
Mirrors :class:`agent_framework_hyperlight.HyperlightCodeActProvider` for
the subset of capabilities that apply to the Monty interpreter:
``tools``, ``approval_mode``, ``workspace_root``, ``file_mounts``, and
``resource_limits`` (Monty-only).
"""
DEFAULT_SOURCE_ID = "monty_codeact"
def __init__(
self,
source_id: str = DEFAULT_SOURCE_ID,
*,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
approval_mode: ApprovalMode | None = None,
workspace_root: str | Path | None = None,
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
resource_limits: dict[str, Any] | None = None,
) -> None:
super().__init__(source_id)
self._execute_code_tool = MontyExecuteCodeTool(
tools=tools,
approval_mode=approval_mode,
workspace_root=workspace_root,
file_mounts=file_mounts,
resource_limits=resource_limits,
)
def add_tools(
self,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
) -> None:
"""Add provider-owned Monty tools."""
self._execute_code_tool.add_tools(tools)
def get_tools(self) -> list[FunctionTool]:
"""Return the provider-owned Monty tools."""
return self._execute_code_tool.get_tools()
def remove_tool(self, name: str) -> None:
"""Remove one provider-owned Monty tool by name."""
self._execute_code_tool.remove_tool(name)
def clear_tools(self) -> None:
"""Remove all provider-owned Monty tools."""
self._execute_code_tool.clear_tools()
def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None:
"""Add provider-managed file mounts."""
self._execute_code_tool.add_file_mounts(file_mounts)
def get_file_mounts(self) -> list[FileMount]:
"""Return the provider-managed file mounts (excluding ``workspace_root``)."""
return self._execute_code_tool.get_file_mounts()
def remove_file_mount(self, mount_path: str) -> None:
"""Remove one provider-managed file mount by its sandbox path."""
self._execute_code_tool.remove_file_mount(mount_path)
def clear_file_mounts(self) -> None:
"""Remove all provider-managed file mounts."""
self._execute_code_tool.clear_file_mounts()
async def before_run(
self,
*,
agent: Any,
session: AgentSession | None,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Inject CodeAct instructions and a run-scoped execute_code tool before each run."""
run_tool = self._execute_code_tool.create_run_tool()
state[self.source_id] = run_tool.build_serializable_state()
context.extend_instructions(self.source_id, run_tool.build_instructions(tools_visible_to_model=False))
context.extend_tools(self.source_id, [run_tool])
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Public types for ``agent-framework-monty``.
Mirrors ``agent_framework_hyperlight._types`` where the Monty runtime exposes
an equivalent concept so users can move between the two providers with minimal
churn.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal, NamedTuple, TypeAlias
#: Allowed Monty mount modes. ``overlay`` (the Monty default) buffers writes
#: in-memory and is therefore not visible to the host after execution.
#: ``read-only`` rejects writes. ``read-write`` writes through to the host
#: directory.
MountMode: TypeAlias = Literal["overlay", "read-only", "read-write"]
class FileMount(NamedTuple):
"""Map a host directory into the Monty sandbox.
Mirrors :class:`agent_framework_hyperlight.FileMount` with two extra
fields that surface Monty's underlying ``MountDir`` capabilities:
``mode`` selects read-only / read-write / overlay semantics, and
``write_bytes_limit`` caps the total bytes written through this mount.
"""
host_path: str | Path
mount_path: str
mode: MountMode = "overlay"
write_bytes_limit: int | None = None
FileMountHostPath: TypeAlias = str | Path
FileMountInput: TypeAlias = str | tuple[FileMountHostPath, str] | FileMount
+107
View File
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-monty"
description = "Monty CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260518"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.4.0,<2",
"pydantic-monty>=0,<0.1",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_monty"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_monty"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_monty"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_monty --cov-report=term-missing:skip-covered tests'
[tool.poe.tasks.test-integration]
help = "Run integration tests for this package (requires pydantic-monty)."
cmd = 'pytest -m "integration" tests'
[tool.flit.module]
name = "agent_framework_monty"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,642 @@
# Copyright (c) Microsoft. All rights reserved.
"""Hermetic unit tests for ``agent_framework_monty``.
These tests inject a fake Monty runtime via ``monkeypatch`` so they run without
the real ``pydantic-monty`` package doing any work. End-to-end tests against
the real runtime live in ``test_monty_codeact_integration.py``.
"""
from __future__ import annotations
import json
import sys
import types
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Annotated, Any
from unittest.mock import MagicMock
import pytest
from agent_framework import Content, FunctionTool, Message, tool
from agent_framework._sessions import SessionContext
from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool
from agent_framework_monty import _execute_code_tool as execute_code_module
from agent_framework_monty import _monty_bridge as bridge_module
# ---------------------------------------------------------------------------
# Fake Monty runtime - drop-in replacement for pydantic_monty
# ---------------------------------------------------------------------------
@dataclass
class _FakeMontyComplete:
output: Any = None
@dataclass
class _FakeFunctionSnapshot:
function_name: str
call_id: int
args: tuple[Any, ...] = ()
kwargs: dict[str, Any] = field(default_factory=dict)
is_os_function: bool = False
_script: _FakeScript | None = None
def resume(self, payload: Any) -> Any:
assert self._script is not None, "Snapshot must be attached to a script."
return self._script.advance(("function_resume", self, payload))
@dataclass
class _FakeFutureSnapshot:
pending_call_ids: list[int]
_script: _FakeScript | None = None
def resume(self, payload: Any) -> Any:
assert self._script is not None, "Snapshot must be attached to a script."
return self._script.advance(("future_resume", self, payload))
@dataclass
class _FakeNameLookupSnapshot:
variable_name: str
@dataclass
class _PrintAction:
"""Marker pushed onto a script to emit captured stdout via the print callback."""
text: str
class _FakeScript:
"""Replayable Monty progress script with a resume log."""
def __init__(self, items: Iterable[Any]) -> None:
self._queue: list[Any] = list(items)
self.resume_log: list[tuple[str, Any, Any]] = []
def attach(self, snapshot: Any) -> Any:
snapshot._script = self
return snapshot
def next_item(self) -> Any:
if not self._queue:
return _FakeMontyComplete(output=None)
item = self._queue.pop(0)
if isinstance(item, _FakeMontyComplete):
return item
if isinstance(item, _PrintAction):
return item
if isinstance(item, _FakeNameLookupSnapshot):
return item
return self.attach(item)
def advance(self, log_entry: tuple[str, Any, Any]) -> Any:
self.resume_log.append(log_entry)
return self.next_item()
_current_script: list[_FakeScript | None] = [None]
def _set_script(*items: Any) -> _FakeScript:
script = _FakeScript(items)
_current_script[0] = script
return script
def _get_script() -> _FakeScript:
script = _current_script[0]
assert script is not None, "Test must call _set_script(...) before running code."
return script
class _FakeMonty:
def __init__(
self,
code: str,
*,
script_name: str,
type_check: bool,
type_check_stubs: str | None,
) -> None:
self.code = code
self.script_name = script_name
self.type_check = type_check
self.type_check_stubs = type_check_stubs
self._script = _get_script()
def start(self, *, print_callback: Any) -> Any:
while True:
item = self._script.next_item()
if isinstance(item, _PrintAction):
print_callback("stdout", item.text)
continue
return item
@pytest.fixture(autouse=True)
def fake_monty_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Install a fake ``pydantic_monty`` module for the duration of each test."""
fake = types.ModuleType("pydantic_monty")
fake.Monty = _FakeMonty # type: ignore[attr-defined]
fake.MontyComplete = _FakeMontyComplete # type: ignore[attr-defined]
fake.FunctionSnapshot = _FakeFunctionSnapshot # type: ignore[attr-defined]
fake.FutureSnapshot = _FakeFutureSnapshot # type: ignore[attr-defined]
fake.NameLookupSnapshot = _FakeNameLookupSnapshot # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "pydantic_monty", fake)
_current_script[0] = None
yield
_current_script[0] = None
# ---------------------------------------------------------------------------
# Sample tools used across tests
# ---------------------------------------------------------------------------
@tool
def add_tool(
a: Annotated[int, "First addend"],
b: Annotated[int, "Second addend"],
) -> int:
"""Add two integers."""
return a + b
@tool
def mul_tool(
a: Annotated[int, "First factor"],
b: Annotated[int, "Second factor"],
) -> int:
"""Multiply two integers."""
return a * b
@tool(approval_mode="always_require")
def dangerous_tool(payload: Annotated[str, "Anything"]) -> str:
"""A tool that always requires approval."""
return payload
# ---------------------------------------------------------------------------
# MontyExecuteCodeTool tests
# ---------------------------------------------------------------------------
def test_tool_construction_defaults() -> None:
monty_tool = MontyExecuteCodeTool()
assert monty_tool.name == "execute_code"
assert monty_tool.approval_mode == "never_require"
assert monty_tool.get_tools() == []
def test_add_remove_clear_tools_round_trip() -> None:
monty_tool = MontyExecuteCodeTool()
monty_tool.add_tools([add_tool, mul_tool])
assert [t.name for t in monty_tool.get_tools()] == ["add_tool", "mul_tool"]
monty_tool.remove_tool("add_tool")
assert [t.name for t in monty_tool.get_tools()] == ["mul_tool"]
with pytest.raises(KeyError):
monty_tool.remove_tool("missing")
monty_tool.clear_tools()
assert monty_tool.get_tools() == []
def test_approval_required_tool_gates_execute_code() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
assert monty_tool.approval_mode == "never_require"
monty_tool.add_tools([dangerous_tool])
assert monty_tool.approval_mode == "always_require"
monty_tool.remove_tool("dangerous_tool")
assert monty_tool.approval_mode == "never_require"
def test_default_approval_mode_always_require_is_sticky() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool], approval_mode="always_require")
assert monty_tool.approval_mode == "always_require"
monty_tool.clear_tools()
assert monty_tool.approval_mode == "always_require"
def test_dynamic_description_reflects_registered_tools() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
description = monty_tool.description
assert "add_tool" in description
assert "Monty" in description
monty_tool.add_tools([mul_tool])
description_updated = monty_tool.description
assert "mul_tool" in description_updated
def test_create_run_tool_snapshots_current_state() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool], approval_mode="never_require")
run_tool = monty_tool.create_run_tool()
assert run_tool is not monty_tool
assert [t.name for t in run_tool.get_tools()] == ["add_tool"]
assert run_tool.approval_mode == monty_tool.approval_mode
# Mutating the original must not leak into the snapshot.
monty_tool.add_tools([mul_tool])
assert [t.name for t in run_tool.get_tools()] == ["add_tool"]
def test_build_serializable_state_matches_effective_config() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool, dangerous_tool])
state = monty_tool.build_serializable_state()
assert state["runtime"] == "monty"
assert state["approval_mode"] == "always_require"
assert set(state["tool_names"]) == {"add_tool", "dangerous_tool"}
assert state["workspace_root"] is None
assert state["file_mounts"] == []
assert state["resource_limits"] is None
def test_file_mounts_normalized_and_round_tripped(tmp_path: Path) -> None:
from agent_framework_monty import FileMount
from agent_framework_monty._execute_code_tool import _normalize_mount_path
host_a = tmp_path / "a"
host_a.mkdir()
host_b = tmp_path / "b"
host_b.mkdir()
monty_tool = MontyExecuteCodeTool(
file_mounts=[
str(host_a), # shorthand: same path on both sides
(str(host_b), "/work"), # explicit tuple
FileMount(host_path=host_a, mount_path="/data", mode="read-only"),
],
)
mounts = monty_tool.get_file_mounts()
by_mount = {m.mount_path: m for m in mounts}
# The shorthand string is normalized through _normalize_mount_path (POSIX-style),
# so on Windows `C:\\...` becomes `/C:/...`. Compare against the same normalizer.
shorthand_key = _normalize_mount_path(str(host_a))
assert set(by_mount) == {shorthand_key, "/work", "/data"}
assert by_mount["/work"].host_path == host_b.resolve()
assert by_mount["/data"].mode == "read-only"
assert by_mount[shorthand_key].mode == "overlay" # default
def test_workspace_root_auto_mounts_at_input(tmp_path: Path) -> None:
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
mounts = monty_tool._effective_mounts()
assert any(m.mount_path == "/input" and m.mode == "read-write" for m in mounts)
def test_workspace_root_yields_to_explicit_input_mount(tmp_path: Path) -> None:
from agent_framework_monty import FileMount
explicit = tmp_path / "explicit"
explicit.mkdir()
monty_tool = MontyExecuteCodeTool(
workspace_root=tmp_path,
file_mounts=[FileMount(host_path=explicit, mount_path="/input", mode="read-only")],
)
input_mounts = [m for m in monty_tool._effective_mounts() if m.mount_path == "/input"]
assert len(input_mounts) == 1
assert input_mounts[0].mode == "read-only"
assert input_mounts[0].host_path == explicit.resolve()
def test_remove_file_mount_raises_on_missing() -> None:
monty_tool = MontyExecuteCodeTool()
with pytest.raises(KeyError):
monty_tool.remove_file_mount("/never-added")
def test_dynamic_description_mentions_filesystem_when_mounts_configured(tmp_path: Path) -> None:
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
description = monty_tool.description
assert "Filesystem access is enabled" in description
assert "/input" in description
def test_dynamic_description_default_mentions_no_filesystem() -> None:
monty_tool = MontyExecuteCodeTool()
description = monty_tool.description
assert "Filesystem access is unavailable" in description
def test_resource_limits_round_trip() -> None:
monty_tool = MontyExecuteCodeTool(resource_limits={"max_duration_secs": 5.0})
assert monty_tool.resource_limits == {"max_duration_secs": 5.0}
state = monty_tool.build_serializable_state()
assert state["resource_limits"] == {"max_duration_secs": 5.0}
def test_build_instructions_includes_registered_tools() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
instructions = monty_tool.build_instructions(tools_visible_to_model=False)
assert "add_tool" in instructions
assert "execute_code" in instructions
assert "asyncio.gather" in instructions
def test_execute_code_filtered_out_when_added_as_tool() -> None:
spurious = FunctionTool(
name="execute_code",
description="should not appear",
func=lambda: None,
)
monty_tool = MontyExecuteCodeTool(tools=[spurious, add_tool])
assert [t.name for t in monty_tool.get_tools()] == ["add_tool"]
# ---------------------------------------------------------------------------
# _run_code behavior with the fake Monty runtime
# ---------------------------------------------------------------------------
async def test_run_code_with_no_tools_returns_default_text() -> None:
_set_script(_FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool()
result = await monty_tool._run_code(code="None")
assert len(result) == 1
assert isinstance(result[0], Content)
async def test_run_code_surfaces_stdout_and_output() -> None:
_set_script(_PrintAction("hello\n"), _FakeMontyComplete(output=42))
monty_tool = MontyExecuteCodeTool()
result = await monty_tool._run_code(code="print('hello')")
text_contents = [c for c in result if c.type == "text"]
assert any("hello" in (c.text or "") for c in text_contents)
assert any(
(c.text or "").strip() and json.loads(c.text or "null") == 42
for c in text_contents
if (c.text or "").strip().isdigit()
)
async def test_run_code_direct_typed_call_invokes_registered_tool() -> None:
func_snapshot = _FakeFunctionSnapshot(
function_name="add_tool",
call_id=1,
kwargs={"a": 2, "b": 3},
)
future_snapshot = _FakeFutureSnapshot(pending_call_ids=[1])
script = _set_script(func_snapshot, future_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="await add_tool(a=2, b=3)")
payloads = [payload for _, _, payload in script.resume_log]
assert {"future": ...} in payloads
final_resume = next(p for p in payloads if isinstance(p, dict) and 1 in p)
assert final_resume[1] == {"return_value": 5}
async def test_run_code_call_tool_fallback_invokes_registered_tool() -> None:
func_snapshot = _FakeFunctionSnapshot(
function_name="call_tool",
call_id=7,
args=("add_tool",),
kwargs={"a": 4, "b": 8},
)
future_snapshot = _FakeFutureSnapshot(pending_call_ids=[7])
script = _set_script(func_snapshot, future_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="await call_tool('add_tool', a=4, b=8)")
payloads = [payload for _, _, payload in script.resume_log]
final_resume = next(p for p in payloads if isinstance(p, dict) and 7 in p)
assert final_resume[7] == {"return_value": 12}
async def test_run_code_unknown_tool_returns_nameerror_resume() -> None:
func_snapshot = _FakeFunctionSnapshot(
function_name="does_not_exist",
call_id=11,
)
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="await does_not_exist()")
payloads = [payload for _, _, payload in script.resume_log]
assert any(isinstance(p, dict) and p.get("exc_type") == "NameError" for p in payloads)
async def test_run_code_os_function_is_rejected_with_permissionerror() -> None:
os_snapshot = _FakeFunctionSnapshot(
function_name="os.listdir",
call_id=12,
is_os_function=True,
)
script = _set_script(os_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="import os; os.listdir('.')")
payloads = [payload for _, _, payload in script.resume_log]
assert any(isinstance(p, dict) and p.get("exc_type") == "PermissionError" for p in payloads)
async def test_when_any_returns_nameerror_now_that_it_is_removed() -> None:
"""`when_any` is no longer part of the DSL and should resolve to a NameError."""
func_snapshot = _FakeFunctionSnapshot(
function_name="when_any",
call_id=99,
args=([{"tool": "add_tool", "kwargs": {"a": 1, "b": 2}}],),
)
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="await when_any([{'tool': 'add_tool', 'kwargs': {'a': 1, 'b': 2}}])")
payloads = [payload for _, _, payload in script.resume_log]
assert any(isinstance(p, dict) and p.get("exc_type") == "NameError" for p in payloads)
async def test_run_code_call_tool_with_unregistered_name_returns_error() -> None:
func_snapshot = _FakeFunctionSnapshot(
function_name="call_tool",
call_id=20,
args=("missing",),
kwargs={},
)
script = _set_script(func_snapshot, _FakeMontyComplete(output=None))
monty_tool = MontyExecuteCodeTool(tools=[add_tool])
await monty_tool._run_code(code="await call_tool('missing')")
payloads = [payload for _, _, payload in script.resume_log]
assert any(
isinstance(p, dict) and p.get("exc_type") == "ValueError" and "Tool 'missing'" in p.get("message", "")
for p in payloads
)
async def test_run_code_returns_error_content_on_runtime_failure(monkeypatch: pytest.MonkeyPatch) -> None:
class _BoomBridge:
def __init__(self, tool_map: Any, **_: Any) -> None:
pass
async def run(self, code: str) -> dict[str, Any]:
raise RuntimeError("boom")
monkeypatch.setattr(execute_code_module, "InlineCodeBridge", _BoomBridge)
monty_tool = MontyExecuteCodeTool()
result = await monty_tool._run_code(code="x = 1")
assert len(result) == 1
assert result[0].type == "error"
assert "boom" in (result[0].error_details or "")
# ---------------------------------------------------------------------------
# MontyCodeActProvider tests
# ---------------------------------------------------------------------------
async def test_provider_injects_execute_code_tool_and_instructions() -> None:
provider = MontyCodeActProvider(tools=[add_tool])
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
state: dict[str, Any] = {}
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
assert state["monty_codeact"]["tool_names"] == ["add_tool"]
assert any("add_tool" in instruction for instruction in context.instructions)
assert len(context.tools) == 1
assert isinstance(context.tools[0], MontyExecuteCodeTool)
# The injected tool is a per-run snapshot, not the provider's stored copy.
assert context.tools[0] is not provider._execute_code_tool # type: ignore[attr-defined]
def test_provider_delegates_tool_management_to_internal_tool() -> None:
provider = MontyCodeActProvider()
provider.add_tools([add_tool, mul_tool])
assert [t.name for t in provider.get_tools()] == ["add_tool", "mul_tool"]
provider.remove_tool("add_tool")
assert [t.name for t in provider.get_tools()] == ["mul_tool"]
provider.clear_tools()
assert provider.get_tools() == []
# ---------------------------------------------------------------------------
# generate_type_stubs - signature smoke test
# ---------------------------------------------------------------------------
def test_generate_type_stubs_emits_dsl_and_tool_signatures() -> None:
def custom(x: int, y: str = "z") -> bool:
"""Stub-test tool."""
return True
stubs = bridge_module.generate_type_stubs({"custom": custom})
assert "async def call_tool(name: str, **kwargs: Any) -> Any:" in stubs
assert "async def custom(x: int, y: str = ...) -> bool:" in stubs
assert "when_any" not in stubs
def test_generate_type_stubs_preserves_none_and_optional() -> None:
def nullable_return(x: int) -> None:
"""Returns nothing."""
return
def optional_param(x: int | None = None) -> bool: # noqa: UP045 - intentional
"""Optional via typing.Optional."""
return x is None
def union_param(x: int | str | None) -> str: # noqa: UP007 - intentional
"""Union with None."""
return str(x)
stubs = bridge_module.generate_type_stubs({
"nullable_return": nullable_return,
"optional_param": optional_param,
"union_param": union_param,
})
# ``None`` return must round-trip as None, not Any.
assert "async def nullable_return(x: int) -> None:" in stubs
# ``Optional[X]`` is ``Union[X, None]`` at runtime; preserve None.
assert "async def optional_param(x: int | None = ...) -> bool:" in stubs
# Multi-arm union with None.
assert "async def union_param(x: int | str | None) -> str:" in stubs
def test_generate_type_stubs_skips_non_identifier_tool_names() -> None:
"""Tool names that are not valid Python identifiers must not be splatted into stub source.
The model can still reach them via ``call_tool("weird-name", ...)`` at
runtime; they just don't get type-checked stubs.
"""
def evil(x: int) -> int:
return x
def normal(x: int) -> int:
return x
stubs = bridge_module.generate_type_stubs({
# Hyphens are not valid identifier chars.
"weird-name": evil,
# Newlines in the name would inject arbitrary stub source.
"broken\n pass\nasync def injected": evil,
# Python keywords are valid identifiers per ``str.isidentifier()`` but
# would still produce uncompilable stubs.
"async": evil,
# Real tool that should still appear.
"normal": normal,
})
assert "async def normal(x: int) -> int:" in stubs
assert "weird-name" not in stubs
assert "injected" not in stubs
assert "async def async(" not in stubs
async def test_invoke_tool_awaits_partial_wrapped_async_method() -> None:
"""A FunctionTool callback registered via partial(FunctionTool.invoke, ...) must be awaited.
Regression for PR #5915 review feedback: relying on ``inspect.iscoroutinefunction``
to choose between ``await`` and ``asyncio.to_thread`` is fragile for
``functools.partial`` wrappers (cpython#98590) and would surface the
returned coroutine as a JSON-serialization error instead of the real
tool result. The bridge must always ``await`` entries in ``self.tool_map``.
"""
from functools import partial
from agent_framework_monty._monty_bridge import InlineCodeBridge
@tool
def adder(a: Annotated[int, ""], b: Annotated[int, ""]) -> int:
"""Add."""
return a + b
# Mirrors what _make_tool_callback returns.
cb = partial(adder.invoke, skip_parsing=True)
bridge = InlineCodeBridge({"adder": cb})
cid, payload = await bridge._invoke_tool(7, "adder", {"a": 6, "b": 7})
assert cid == 7
assert payload == {"return_value": 13}, payload
@@ -0,0 +1,601 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for ``agent_framework_monty`` exercising the real Monty runtime.
These tests import the real ``pydantic-monty`` package and run actual Python
code through it via :class:`MontyExecuteCodeTool`. They are marked
``@pytest.mark.integration`` and are skipped automatically when
``pydantic_monty`` is unavailable.
"""
from __future__ import annotations
import asyncio
import importlib.util
import time
from typing import Annotated, Any
from unittest.mock import MagicMock
import pytest
from agent_framework import Agent, Content, Message, tool
from agent_framework._sessions import SessionContext
from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool
def _monty_integration_skip_reason() -> str | None:
if importlib.util.find_spec("pydantic_monty") is None:
return "pydantic-monty is not installed."
return None
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
_monty_integration_skip_reason() is not None,
reason=_monty_integration_skip_reason() or "Monty integration tests are disabled.",
),
]
# ---------------------------------------------------------------------------
# Sample tools
# ---------------------------------------------------------------------------
@tool
def add(
a: Annotated[int, "First addend"],
b: Annotated[int, "Second addend"],
) -> int:
"""Return ``a + b``."""
return a + b
@tool
def multiply(
a: Annotated[int, "First factor"],
b: Annotated[int, "Second factor"],
) -> int:
"""Return ``a * b``."""
return a * b
@tool
async def async_echo(value: Annotated[str, "Value to echo"]) -> str:
"""Return ``value`` after a no-op await."""
await asyncio.sleep(0)
return value
def _async_slow_factory(label: str, delay: float) -> Any:
@tool(name=f"slow_{label}")
async def slow(value: Annotated[int, "Input"]) -> int:
"""Sleep asynchronously, then return value untouched."""
await asyncio.sleep(delay)
return value
return slow
@tool(approval_mode="always_require")
def restricted(payload: Annotated[str, "Any text"]) -> str:
"""A tool that always requires approval."""
return payload
def _text_outputs(contents: list[Content]) -> list[str]:
return [c.text or "" for c in contents if c.type == "text"]
# ---------------------------------------------------------------------------
# Basic execution
# ---------------------------------------------------------------------------
async def test_plain_python_print_round_trips() -> None:
monty_tool = MontyExecuteCodeTool()
result = await monty_tool._run_code(code="print('hello world')")
texts = _text_outputs(result)
assert any("hello world" in text for text in texts)
async def test_last_expression_value_is_returned() -> None:
monty_tool = MontyExecuteCodeTool()
result = await monty_tool._run_code(code="5 + 7")
texts = _text_outputs(result)
assert any(text.strip() == "12" for text in texts)
# ---------------------------------------------------------------------------
# Tool dispatch
# ---------------------------------------------------------------------------
async def test_direct_typed_tool_call_invokes_host() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add])
result = await monty_tool._run_code(code="print(await add(a=2, b=3))")
texts = _text_outputs(result)
assert any("5" in text for text in texts)
async def test_call_tool_fallback_invokes_host() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add])
result = await monty_tool._run_code(code="print(await call_tool('add', a=4, b=8))")
texts = _text_outputs(result)
assert any("12" in text for text in texts)
async def test_async_host_tool_is_awaited() -> None:
monty_tool = MontyExecuteCodeTool(tools=[async_echo])
result = await monty_tool._run_code(code="print(await async_echo(value='ping'))")
texts = _text_outputs(result)
assert any("ping" in text for text in texts)
# ---------------------------------------------------------------------------
# Concurrency
# ---------------------------------------------------------------------------
async def test_asyncio_gather_fans_out_tool_calls_concurrently() -> None:
"""Two async tools dispatched via ``asyncio.gather`` should run on the event loop in parallel.
Sync tools cannot fan out (FunctionTool.invoke runs them inline on the event loop),
so this test uses async host tools to verify the bridge's gather pipeline does
not introduce extra serialization.
"""
slow_a = _async_slow_factory("a", delay=0.25)
slow_b = _async_slow_factory("b", delay=0.25)
monty_tool = MontyExecuteCodeTool(tools=[slow_a, slow_b])
code = """
results = await asyncio.gather(slow_a(value=1), slow_b(value=2))
print(results)
"""
start = time.perf_counter()
result = await monty_tool._run_code(code=code)
elapsed = time.perf_counter() - start
texts = _text_outputs(result)
assert any("[1, 2]" in text for text in texts)
# Allow some scheduling slack but verify it's noticeably less than sequential (~0.5s).
assert elapsed < 0.45, f"Expected concurrent execution; took {elapsed:.3f}s"
# ---------------------------------------------------------------------------
# Sandbox safety + type checking
# ---------------------------------------------------------------------------
async def test_type_check_rejects_wrong_argument_type() -> None:
invocation_count = {"count": 0}
@tool
def typed_add(
a: Annotated[int, "First"],
b: Annotated[int, "Second"],
) -> int:
"""Add two ints; records invocations."""
invocation_count["count"] += 1
return a + b
monty_tool = MontyExecuteCodeTool(tools=[typed_add])
result = await monty_tool._run_code(code="print(await typed_add(a='not an int', b=3))")
texts = _text_outputs(result)
errors = [c for c in result if c.type == "error"]
# Either ty raises and surfaces as an error Content, or Monty reports the typing error in stdout.
assert errors or any("type" in text.lower() or "monty" in text.lower() for text in texts)
assert invocation_count["count"] == 0
async def test_os_calls_are_blocked() -> None:
monty_tool = MontyExecuteCodeTool()
code = """
try:
import os
os.listdir('/')
print('LEAKED')
except PermissionError as exc:
print('blocked:', exc)
except Exception as exc:
print('other:', type(exc).__name__)
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
assert not any("LEAKED" in text for text in texts)
assert any("blocked" in text or "PermissionError" in text or "other" in text for text in texts)
async def test_unknown_tool_call_returns_clean_error() -> None:
monty_tool = MontyExecuteCodeTool(tools=[add])
code = """
try:
await call_tool('missing')
except Exception as exc:
print('err:', type(exc).__name__, str(exc))
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
assert any("missing" in text for text in texts)
# ---------------------------------------------------------------------------
# Print capture
# ---------------------------------------------------------------------------
async def test_print_truncation_caps_output() -> None:
monty_tool = MontyExecuteCodeTool()
# Emit more than MAX_PRINT_OUTPUT_CHARS bytes of output.
code = """
for _ in range(2000):
print('X' * 64)
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
combined = "\n".join(texts)
assert len(combined) <= 9000 # MAX_PRINT_OUTPUT_CHARS=8192 plus a small truncation marker
assert "[stdout truncated]" in combined
# ---------------------------------------------------------------------------
# Filesystem (workspace_root, file_mounts, output capture, resource limits)
# ---------------------------------------------------------------------------
async def test_workspace_root_reads_seed_files_from_host(tmp_path: Any) -> None:
seed = tmp_path / "seed.txt"
seed.write_text("hello from host", encoding="utf-8")
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
code = """
import pathlib
data = pathlib.Path('/input/seed.txt').read_text()
print(data)
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
assert any("hello from host" in text for text in texts)
async def test_workspace_root_writes_are_captured_as_content(tmp_path: Any) -> None:
monty_tool = MontyExecuteCodeTool(workspace_root=tmp_path)
code = """
import pathlib
pathlib.Path('/input/report.txt').write_text('result-payload')
print('wrote report')
"""
result = await monty_tool._run_code(code=code)
data_contents = [c for c in result if c.type == "data"]
assert len(data_contents) == 1, [c.type for c in result]
written = data_contents[0]
# Content.from_data stores bytes as a base64-encoded data: URI.
import base64
assert written.uri is not None
payload = written.uri.split(",", 1)[1]
assert base64.b64decode(payload) == b"result-payload"
assert (written.additional_properties or {}).get("path") == "/input/report.txt"
# And the file actually landed on the host filesystem (read-write mode).
assert (tmp_path / "report.txt").read_text() == "result-payload"
async def test_read_only_mount_writes_are_rejected_and_not_captured(tmp_path: Any) -> None:
from agent_framework_monty import FileMount
seed = tmp_path / "seed.txt"
seed.write_text("ro-content", encoding="utf-8")
monty_tool = MontyExecuteCodeTool(
file_mounts=[FileMount(host_path=tmp_path, mount_path="/ro", mode="read-only")],
)
code = """
import pathlib
print(pathlib.Path('/ro/seed.txt').read_text())
try:
pathlib.Path('/ro/should-not-exist.txt').write_text('nope')
print('LEAKED')
except Exception as exc:
print('write blocked:', type(exc).__name__)
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
assert any("ro-content" in t for t in texts)
assert not any("LEAKED" in t for t in texts)
# No write went to host; no captured Content for the rejected write.
assert not (tmp_path / "should-not-exist.txt").exists()
assert not any(c.type == "data" for c in result)
async def test_overlay_mount_writes_do_not_persist_to_host(tmp_path: Any) -> None:
from agent_framework_monty import FileMount
monty_tool = MontyExecuteCodeTool(
file_mounts=[FileMount(host_path=tmp_path, mount_path="/overlay", mode="overlay")],
)
code = """
import pathlib
pathlib.Path('/overlay/scratch.txt').write_text('overlay-only')
print('wrote')
"""
result = await monty_tool._run_code(code=code)
assert any("wrote" in t for t in _text_outputs(result))
# Overlay writes stay in-memory: nothing on host, nothing captured.
assert not (tmp_path / "scratch.txt").exists()
assert not any(c.type == "data" for c in result)
async def test_resource_limit_short_duration_aborts_long_loop() -> None:
# Cap CPU time hard; a busy loop should be killed before it can print 'done'.
monty_tool = MontyExecuteCodeTool(resource_limits={"max_duration_secs": 0.2})
code = """
total = 0
for i in range(10_000_000):
total += i
print('done', total)
"""
result = await monty_tool._run_code(code=code)
# Result is either an error Content (timeout surfaces as RuntimeError) or
# truncated stdout without the 'done' marker.
texts = _text_outputs(result)
assert not any("done" in t for t in texts), texts
# ---------------------------------------------------------------------------
# Symlink escape regression (MSRC-style)
# ---------------------------------------------------------------------------
def _symlinks_supported(tmp: Any) -> bool:
"""Return True if the current platform/environment supports symlinks.
Mirrors python/packages/core/tests/core/test_skills.py so the symlink
regression tests are skipped on restricted Windows CI runners instead of
failing on ``OSError`` / ``NotImplementedError`` during creation.
"""
test_target = tmp / "_symlink_test_target"
test_link = tmp / "_symlink_test_link"
try:
test_target.write_text("test", encoding="utf-8")
test_link.symlink_to(test_target)
return True
except (OSError, NotImplementedError):
return False
finally:
test_link.unlink(missing_ok=True)
test_target.unlink(missing_ok=True)
async def test_symlinks_inside_workspace_are_not_followed_by_runtime(tmp_path: Any) -> None:
"""A pre-existing symlink in workspace_root must NOT let sandbox code read its target.
Monty's mount layer enforces this (PermissionError at the OS bridge), but we
pin the behavior here so any future change to the OS dispatch path is
detected.
"""
if not _symlinks_supported(tmp_path):
pytest.skip("Symlinks not supported on this platform/environment")
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside_secret.txt"
outside.write_text("SECRET_OUTSIDE_WORKSPACE", encoding="utf-8")
(workspace / "leak.txt").symlink_to(outside)
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
code = """
import pathlib
try:
print('read:', pathlib.Path('/input/leak.txt').read_text())
except PermissionError as exc:
print('blocked:', exc)
except Exception as exc:
print('other:', type(exc).__name__, exc)
"""
result = await monty_tool._run_code(code=code)
texts = _text_outputs(result)
assert not any("SECRET_OUTSIDE_WORKSPACE" in t for t in texts), texts
assert any("blocked" in t or "PermissionError" in t or "other" in t for t in texts), texts
async def test_post_capture_skips_symlinks_pointing_outside_workspace(tmp_path: Any) -> None:
"""File capture must NOT read through a symlink that points outside the mount.
Reproduces the MSRC-reported Hyperlight pattern in Monty's post-execution
file-capture path: an attacker-placed ``workspace/leak.txt -> /outside/secret``
must not be returned as Content.
"""
if not _symlinks_supported(tmp_path):
pytest.skip("Symlinks not supported on this platform/environment")
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside_secret.txt"
outside.write_text("SECRET_OUTSIDE_WORKSPACE", encoding="utf-8")
(workspace / "leak.txt").symlink_to(outside)
outside_dir = tmp_path / "outside_dir"
outside_dir.mkdir()
(outside_dir / "deep.txt").write_text("DEEP_SECRET", encoding="utf-8")
(workspace / "leak_dir").symlink_to(outside_dir)
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
# Run trivial code so the post-execution scan fires.
result = await monty_tool._run_code(code="print('ran')")
# Inspect the URIs of any returned data Content items.
import base64
leaked_paths: list[str] = []
leaked_bodies: list[bytes] = []
for content in result:
if content.type != "data" or not content.uri:
continue
payload = content.uri.split(",", 1)[1] if "," in content.uri else ""
try:
body = base64.b64decode(payload)
except Exception: # noqa: BLE001
body = b""
leaked_bodies.append(body)
leaked_paths.append((content.additional_properties or {}).get("path", ""))
assert not any(b"SECRET_OUTSIDE_WORKSPACE" in body for body in leaked_bodies), (
"Symlink file outside workspace was captured: " + repr(leaked_paths)
)
assert not any(b"DEEP_SECRET" in body for body in leaked_bodies), (
"Symlinked directory escape was captured: " + repr(leaked_paths)
)
async def test_post_capture_still_returns_real_writes_when_symlinks_present(tmp_path: Any) -> None:
"""The symlink-skipping logic must not regress capture of legitimate sandbox writes."""
if not _symlinks_supported(tmp_path):
pytest.skip("Symlinks not supported on this platform/environment")
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside_secret.txt"
outside.write_text("SHOULD_NEVER_LEAK", encoding="utf-8")
(workspace / "leak.txt").symlink_to(outside)
monty_tool = MontyExecuteCodeTool(workspace_root=workspace)
code = """
import pathlib
pathlib.Path('/input/report.txt').write_text('legit-output')
print('wrote')
"""
result = await monty_tool._run_code(code=code)
import base64
data_items = [c for c in result if c.type == "data" and c.uri]
# Exactly one new file should be captured: report.txt.
assert len(data_items) == 1, [(c.additional_properties or {}).get("path") for c in data_items]
item = data_items[0]
assert (item.additional_properties or {}).get("path") == "/input/report.txt"
payload = item.uri.split(",", 1)[1] if item.uri and "," in item.uri else ""
assert base64.b64decode(payload) == b"legit-output"
# ---------------------------------------------------------------------------
# Provider + approval gating
# ---------------------------------------------------------------------------
async def test_provider_run_tool_executes_real_monty_end_to_end() -> None:
provider = MontyCodeActProvider(tools=[add])
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
state: dict[str, Any] = {}
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
run_tool = context.tools[0]
assert isinstance(run_tool, MontyExecuteCodeTool)
result = await run_tool._run_code(code="print(await add(a=10, b=32))")
texts = _text_outputs(result)
assert any("42" in text for text in texts)
async def test_approval_required_tool_gates_execute_code_end_to_end() -> None:
provider = MontyCodeActProvider(tools=[restricted])
context = SessionContext(input_messages=[Message(role="user", contents=[Content.from_text("hi")])])
state: dict[str, Any] = {}
await provider.before_run(agent=MagicMock(), session=None, context=context, state=state)
run_tool = context.tools[0]
assert isinstance(run_tool, MontyExecuteCodeTool)
assert run_tool.approval_mode == "always_require"
assert state["monty_codeact"]["approval_mode"] == "always_require"
# ---------------------------------------------------------------------------
# End-to-end Agent run with a fake chat client
# ---------------------------------------------------------------------------
async def test_agent_runs_monty_codeact_end_to_end() -> None:
"""A fake chat client emits one execute_code tool call; Monty runs it end-to-end."""
from collections.abc import Awaitable, Mapping, MutableSequence
from agent_framework import (
BaseChatClient,
ChatResponse,
ChatResponseUpdate,
FunctionInvocationLayer,
ResponseStream,
)
class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
self.call_count = 0
def _inner_get_response(
self,
*,
messages: MutableSequence[Message],
stream: bool,
options: Mapping[str, Any],
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
raise AssertionError("Streaming is not used in this integration test.")
async def _get_response() -> ChatResponse:
self.call_count += 1
if self.call_count == 1:
return ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="execute_code_call",
name="execute_code",
arguments={"code": "print(await add(a=6, b=7))"},
)
],
)
)
function_results = [
content for message in messages for content in message.contents if content.type == "function_result"
]
assert len(function_results) == 1
result_content = function_results[0]
result_text = ""
if isinstance(result_content.result, list):
for item in result_content.result:
text = getattr(item, "text", None)
if text:
result_text += text
else:
result_text = str(result_content.result or "")
return ChatResponse(
messages=Message(
role="assistant",
contents=[f"answer: {result_text.strip() or 'none'}"],
)
)
return _get_response()
client = _FakeCodeActChatClient()
provider = MontyCodeActProvider(tools=[add])
agent = Agent(client=client, context_providers=[provider])
response = await agent.run("Add 6 and 7 inside execute_code.")
assert "13" in (response.text or "")
assert client.call_count == 2
+1
View File
@@ -88,6 +88,7 @@ agent-framework-github-copilot = { workspace = true }
agent-framework-hyperlight = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
agent-framework-monty = { workspace = true }
agent-framework-ollama = { workspace = true }
agent-framework-openai = { workspace = true }
agent-framework-orchestrations = { workspace = true }
@@ -1,20 +1,31 @@
# Hyperlight CodeAct context provider
# CodeAct context providers
Demonstrates the provider-owned [Hyperlight](https://github.com/hyperlight-dev/hyperlight)
CodeAct flow. `HyperlightCodeActProvider` injects an `execute_code` tool into the
agent and keeps the registered sandbox tools (`compute`, `fetch_data`) hidden
from the model — the model must call them from inside the sandbox using
`call_tool(...)`.
Demonstrates the provider-owned CodeAct flow with two backends:
| File | Backend | Notes |
|------|---------|-------|
| [`code_act.py`](code_act.py) | [Hyperlight](https://github.com/hyperlight-dev/hyperlight) WASM sandbox via `HyperlightCodeActProvider` | Hardened sandbox with WASM isolation; sandbox tools called via `call_tool(...)`. |
| [`monty_code_act.py`](monty_code_act.py) | [Monty](https://github.com/pydantic/monty) Rust-based Python interpreter via `MontyCodeActProvider` (alpha) | Cross-platform pure interpreter; sandbox tools can be called as typed async functions (`await compute(...)`) or via `call_tool(...)`. |
Both providers inject an `execute_code` tool into the agent and keep the
registered sandbox tools (`compute`, `fetch_data`) hidden from the model — the
model invokes them from inside the sandbox.
## Installation
```bash
pip install agent-framework agent-framework-hyperlight --pre
pip install agent-framework agent-framework-hyperlight --pre # Hyperlight sample
pip install agent-framework agent-framework-monty --pre # Monty sample
```
> The Hyperlight Wasm backend is currently published only for `linux/x86_64` and
> `win32/AMD64` with Python `<3.14`. On other platforms `execute_code` will fail
> at runtime when it tries to create the sandbox.
>
> Monty is cross-platform and has no hypervisor/WASM backend dependency, but it
> interprets a Python subset (e.g. `os`/network/subprocess access is blocked).
> `agent-framework-monty` is an alpha package and is not yet part of
> `agent-framework[all]`; install it explicitly with `--pre`.
## Prerequisites
@@ -25,7 +36,8 @@ pip install agent-framework agent-framework-hyperlight --pre
## Run
```bash
python code_act.py
python code_act.py # Hyperlight
python monty_code_act.py # Monty
```
See [`code_act.py`](code_act.py) for the full annotated example.
See the source files for the full annotated examples.
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Awaitable, Callable
from typing import Annotated, Any, Literal
from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_monty import MontyCodeActProvider
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
"""This sample demonstrates the provider-owned Monty CodeAct flow.
The sample keeps `compute` and `fetch_data` off the direct agent tool surface and
registers them only with `MontyCodeActProvider`. The model therefore sees a
single `execute_code` tool and calls the provider-owned tools from inside the
sandbox - either as typed async functions (`await compute(...)`) or via the
generic `call_tool(...)` fallback.
`MontyCodeActProvider` uses [pydantic-monty](https://github.com/pydantic/monty),
a Rust-based Python interpreter, so it runs cross-platform with no
hypervisor/WASM backend dependency.
Note: `agent-framework-monty` is an alpha package and is not yet part of
`agent-framework[all]`. Install it explicitly with:
pip install agent-framework agent-framework-monty --pre
It is imported as `agent_framework_monty` (no lazy-loading namespace yet).
"""
load_dotenv()
_CYAN = "\033[36m"
_YELLOW = "\033[33m"
_GREEN = "\033[32m"
_DIM = "\033[2m"
_RESET = "\033[0m"
class _ColoredFormatter(logging.Formatter):
"""Dim logger output so it does not compete with sample prints."""
def format(self, record: logging.LogRecord) -> str:
return f"{_DIM}{super().format(record)}{_RESET}"
logging.basicConfig(level=logging.WARNING)
logging.getLogger().handlers[0].setFormatter(
_ColoredFormatter("[%(asctime)s] %(levelname)s: %(message)s"),
)
@function_middleware
async def log_function_calls(
context: FunctionInvocationContext,
call_next: Callable[[], Awaitable[None]],
) -> None:
"""Log tool calls, including readable execute_code blocks."""
import time
function_name = context.function.name
arguments = context.arguments if isinstance(context.arguments, dict) else {}
if function_name == "execute_code" and "code" in arguments:
print(f"\n{_YELLOW}{'─' * 60}")
print("â–¶ execute_code")
print(f"{'─' * 60}{_RESET}")
print(arguments["code"])
print(f"{_YELLOW}{'─' * 60}{_RESET}")
else:
pairs = ", ".join(f"{name}={value!r}" for name, value in arguments.items())
print(f"\n{_YELLOW}â–¶ {function_name}({pairs}){_RESET}")
start = time.perf_counter()
await call_next()
elapsed = time.perf_counter() - start
result = context.result
if function_name == "execute_code" and isinstance(result, list):
for output in result:
if output.type == "text" and output.text:
print(f"{_GREEN}stdout:\n{output.text}{_RESET}")
elif output.type == "error" and output.error_details:
print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}")
else:
print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}")
print(f"{_DIM} ({elapsed:.4f}s){_RESET}")
@tool(approval_mode="never_require")
def compute(
operation: Annotated[
Literal["add", "subtract", "multiply", "divide"],
"Math operation: add, subtract, multiply, or divide.",
],
a: Annotated[float, "First numeric operand."],
b: Annotated[float, "Second numeric operand."],
) -> float:
"""Perform a math operation for sandboxed code."""
operations = {
"add": a + b,
"subtract": a - b,
"multiply": a * b,
"divide": a / b if b else float("inf"),
}
return operations[operation]
@tool(approval_mode="never_require")
async def fetch_data(
table: Annotated[str, "Name of the simulated table to query."],
) -> list[dict[str, Any]]:
"""Fetch records from a named table."""
await asyncio.sleep(0.5)
data: dict[str, list[dict[str, Any]]] = {
"users": [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Charlie", "role": "admin"},
],
"products": [
{"id": 101, "name": "Widget", "price": 9.99},
{"id": 102, "name": "Gadget", "price": 19.99},
],
}
return data.get(table, [])
async def main() -> None:
"""Run the provider-owned Monty CodeAct sample."""
# 1. Create the Monty-backed provider and register sandbox tools on it.
codeact = MontyCodeActProvider(
tools=[compute, fetch_data],
approval_mode="never_require",
)
# 2. Create the client and the agent.
agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="MontyCodeActProviderAgent",
instructions="You are a helpful assistant.",
context_providers=[codeact],
middleware=[log_function_calls],
)
# 3. Run a request that should use execute_code plus provider-owned tools.
query = (
"Fetch all users, find admins, multiply 7*(3*2), and print the users, "
"admins, and multiplication result. Use a single execute_code call. "
"You may call the registered tools directly as typed async functions "
"(`await compute(operation='multiply', a=7, b=6)`) or via "
"`call_tool('compute', ...)`."
)
print(f"{_CYAN}{'=' * 60}")
print("Monty CodeAct provider sample")
print(f"{'=' * 60}{_RESET}")
print(f"{_CYAN}User: {query}{_RESET}")
result = await agent.run(query)
print(f"{_CYAN}Agent: {result.text}{_RESET}")
"""
Sample output (shape only):
============================================================
Monty CodeAct provider sample
============================================================
User: Fetch all users, find admins, multiply 7*(3*2), ...
────────────────────────────────────────────────────────────
â–¶ execute_code
────────────────────────────────────────────────────────────
users = await fetch_data(table="users")
admins = [u for u in users if u["role"] == "admin"]
result = await compute(operation="multiply", a=7, b=6)
print("Users:", users)
print("Admins:", admins)
print("7 * 6 =", result)
────────────────────────────────────────────────────────────
stdout:
Users: [...]
Admins: [...]
7 * 6 = 42.0
(0.5xxx s)
Agent: ...
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -27,6 +27,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
# Agent Framework specific settings
# ==================================
# Observability is enabled by default. Set to "false" to opt out.
# ENABLE_INSTRUMENTATION=false
# Enable sensitive data logging (prompts, responses, etc.)
# WARNING: Only enable in dev/test environments
ENABLE_SENSITIVE_DATA=true
@@ -34,9 +37,6 @@ ENABLE_SENSITIVE_DATA=true
# Optional: Enable console exporters for debugging
# ENABLE_CONSOLE_EXPORTERS=true
# Optional: Enable observability (automatically enabled if env vars are set or configure_otel_providers() is called)
# ENABLE_INSTRUMENTATION=true
# OpenAI specific variables
# ==========================
OPENAI_API_KEY="..."
+187 -175
View File
@@ -1,12 +1,12 @@
# Agent Framework Observability
This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the OpenTelemetry standard.
These samples show how to send Agent Framework observability data to the Application Performance Management (APM) backend of your choice, based on the OpenTelemetry standard.
In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) and the console.
The samples target [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and the console, but any OTLP-compatible backend works.
> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. Or you can use the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
> **Quick Start**: For local development without Azure setup, use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) (runs locally via Docker), or the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [page](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters.
> Other backends such as [Prometheus](https://prometheus.io/docs/introduction/overview/) are also supported. See the [OpenTelemetry Python exporters](https://opentelemetry.io/docs/languages/python/exporters/) page for the full list.
For more information, please refer to the following resources:
@@ -18,19 +18,15 @@ For more information, please refer to the following resources:
## What to expect
The Agent Framework Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of agent/model invocation and tool execution. This allows you to effectively monitor your AI application's performance and accurately track token consumption. It does so based on the Semantic Conventions for GenAI defined by OpenTelemetry, and the workflows emit their own spans to provide end-to-end visibility.
The Agent Framework Python SDK is **natively instrumented** to emit logs, traces, and metrics throughout agent/model invocation and tool execution, so you can monitor your AI application's performance and track token consumption. Instrumentation follows the OpenTelemetry [Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/), and workflows emit their own spans for end-to-end visibility.
Next to what happens in the code when you run, we also make setting up observability as easy as possible. By calling a single function `configure_otel_providers()` from the `agent_framework.observability` module, you can enable telemetry for traces, logs, and metrics. The function automatically reads standard OpenTelemetry environment variables to configure exporters and providers, making it simple to get started.
### MCP trace propagation
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted/provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (for example, `toolbox = await client.get_toolbox(...)`, then passing `toolbox.tools` into `Agent(tools=...)`), because in those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process. As a result, the framework has no opportunity to inject trace context into those requests, and propagating `traceparent`/`tracestate` across that hosted-service boundary is the responsibility of the service runtime, not Agent Framework. If end-to-end distributed tracing to the downstream MCP server is required, use a client-opened MCP transport instead of a hosted connector.
Setting up observability is also easy: a single call to `configure_otel_providers()` from the `agent_framework.observability` module wires up the trace, log, and metric providers. It reads the standard OpenTelemetry environment variables to configure exporters automatically.
### Five patterns for configuring observability
We've identified multiple ways to configure observability in your application, depending on your needs:
> Setting up observability has two parts: (1) **instrumentation**, the code that generates telemetry, and (2) **exporter/provider configuration**, which decides where that telemetry is sent. Agent Framework is natively instrumented and **enabled by default**, so you only need to handle the second part.
There are five common ways to do that, depending on your needs:
**1. Standard otel environment variables, configured for you**
@@ -42,22 +38,29 @@ from agent_framework.observability import configure_otel_providers
# Reads OTEL_EXPORTER_OTLP_* environment variables automatically
configure_otel_providers()
```
Or if you just want console exporters:
```python
from agent_framework.observability import configure_otel_providers
# Enable console exporters via environment variable
configure_otel_providers(enable_console_exporters=True)
# It is also possible to set ENABLE_CONSOLE_EXPORTERS=true in environment
# variables instead of calling `configure_otel_providers()` with the parameter.
# The framework will automatically read that and set up console exporters.
```
This is the **recommended approach** for getting started.
**2. Custom Exporters**
One level more control over the exporters that are created is to do that yourself, and then pass them to `configure_otel_providers()`. We will still create the providers for you, but you can customize the exporters as needed:
For more control, construct exporters yourself and pass them to `configure_otel_providers()`. The framework still creates the providers for you:
```python
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.exporter import Compression
from agent_framework.observability import configure_otel_providers
# Create custom exporters with specific configuration
@@ -67,17 +70,17 @@ exporters = [
OTLPMetricExporter(endpoint="http://localhost:4317"),
]
# These will be added alongside any exporters from environment variables
configure_otel_providers(exporters=exporters, enable_sensitive_data=True)
# These are added alongside any exporters configured from environment variables
configure_otel_providers(exporters=exporters)
```
**3. Third party setup**
**3. Third-party setup**
A lot of third party specific otel package, have their own easy setup methods, for example Azure Monitor has `configure_azure_monitor()`. You can use those methods to setup the third party first, and then call `enable_instrumentation()` from the `agent_framework.observability` module to activate the Agent Framework telemetry code paths. In all these cases, if you already setup observability via environment variables, you don't need to call `enable_instrumentation()` as it will be enabled automatically.
Many third-party OTel packages ship their own setup helpers (for example, Azure Monitor's `configure_azure_monitor()`). You can use those directly — Agent Framework instrumentation is on by default, so no extra wiring is needed. To also capture sensitive data, call `enable_sensitive_telemetry()` from `agent_framework.observability`.
```python
from azure.monitor.opentelemetry import configure_azure_monitor
from agent_framework.observability import create_resource, enable_instrumentation
from agent_framework.observability import create_resource, enable_sensitive_telemetry
# Configure Azure Monitor first
configure_azure_monitor(
@@ -86,10 +89,10 @@ configure_azure_monitor(
enable_live_metrics=True,
)
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
For Microsoft Foundry projects, use `client.configure_azure_monitor()` which retrieves the connection string from the project and configures everything:
```python
@@ -110,7 +113,7 @@ Or with [Langfuse](https://langfuse.com/integrations/frameworks/microsoft-agent-
```python
# environment should be setup correctly, with langfuse urls and keys
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from langfuse import get_client
langfuse = get_client()
@@ -121,9 +124,9 @@ if langfuse.auth_check():
else:
print("Authentication failed. Please check your credentials and host.")
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Agent Framework instrumentation is on by default.
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agent-framework):
@@ -131,53 +134,152 @@ Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agen
```python
import os
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
# Use Opik OTLP settings from your project settings
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "<opik_otlp_endpoint>"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "<opik_otlp_headers>"
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Agent Framework instrumentation is on by default.
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
**4. Manual setup**
Of course you can also do a complete manual setup of exporters, providers, and instrumentation. Please refer to sample [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a comprehensive example of how to manually setup exporters and providers for traces, logs, and metrics that will get sent to the console. This gives you full control over which exporters and providers to use. We do have a helper function `create_resource()` in the `agent_framework.observability` module that you can use to create a resource with the appropriate service name and version based on environment variables or standard defaults for Agent Framework, this is not used in the sample.
**5. Auto-instrumentation (zero-code)**
You can also use the [OpenTelemetry CLI tool](https://opentelemetry.io/docs/instrumentation/python/getting-started/#automatic-instrumentation) to automatically instrument your application without changing any code. Please refer to sample [advanced_zero_code.py](./advanced_zero_code.py) for an example of how to use the CLI tool to enable instrumentation for Agent Framework applications.
For full control, set up providers and exporters yourself. See [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a complete example that sends traces, logs, and metrics to the console. The `create_resource()` helper in `agent_framework.observability` can build a resource with the appropriate service name and version from environment variables (or sensible defaults), although the sample does not use it.
**5. Zero-code provider/exporter configuration**
Because Agent Framework is **natively instrumented** with OpenTelemetry, you do not need to auto-instrument the framework itself. You can, however, use the [`opentelemetry-instrument`](https://opentelemetry.io/docs/zero-code/python/) CLI wrapper to configure the global tracer/meter providers and exporters from environment variables (or CLI flags) at process startup. Your application code then does not need to call `configure_otel_providers()` — the native spans and metrics from Agent Framework are picked up by the globally configured pipeline. See [advanced_zero_code.py](./advanced_zero_code.py) for an example.
### MCP trace propagation
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally configured OpenTelemetry propagator(s) — W3C Trace Context by default (producing `traceparent` and `tracestate`) — so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted or provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (e.g. `toolbox = await client.get_toolbox(...)` then `Agent(tools=toolbox.tools)`). In those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process, so propagating `traceparent`/`tracestate` across that boundary is the service runtime's responsibility. If you need end-to-end distributed tracing to the downstream MCP server, use a client-opened MCP transport instead of a hosted connector.
## Configuration
### Dependencies
As part of Agent Framework we use the following OpenTelemetry packages:
- `opentelemetry-api`
- `opentelemetry-sdk`
- `opentelemetry-semantic-conventions-ai`
Agent Framework's core depends on **`opentelemetry-api`** only — the API package is enough for the instrumentation hooks (spans, meters, log records) to emit telemetry, and it has no runtime side effects when no provider is configured.
We do not install exporters by default, so you will need to add those yourself, this prevents us from installing unnecessary dependencies. For Application Insights, you will need to install `azure-monitor-opentelemetry`. For Aspire Dashboard or other OTLP compatible backends, you will need to install `opentelemetry-exporter-otlp-proto-grpc`. For HTTP protocol support, you will also need to install `opentelemetry-exporter-otlp-proto-http`.
If you want the framework to set up providers / exporters for you via `configure_otel_providers()` (or to use the `create_resource()` / `create_metric_views()` helpers), you also need the OpenTelemetry SDK:
And for many others, different packages are used, so refer to the documentation of the specific exporter you want to use.
```bash
pip install opentelemetry-sdk
```
If `opentelemetry-sdk` is missing, those helper functions raise a clear `ImportError` telling you to install it. Day-to-day instrumentation still works without the SDK as long as some other component (e.g. `azure-monitor-opentelemetry`, your application bootstrap, an APM agent) has configured the global OpenTelemetry providers.
Exporters are **not** installed by default — install only what you need:
- **Application Insights**: `azure-monitor-opentelemetry`
- **Aspire Dashboard or other OTLP/gRPC backends**: `opentelemetry-exporter-otlp-proto-grpc`
- **OTLP over HTTP**: `opentelemetry-exporter-otlp-proto-http`
For other backends, refer to the documentation of the specific exporter.
### Environment variables
The following environment variables are used to turn on/off observability of the Agent Framework:
Agent Framework reads the following environment variables:
- `ENABLE_INSTRUMENTATION`
- `ENABLE_SENSITIVE_DATA`
- `ENABLE_CONSOLE_EXPORTERS`
| Variable | Default | Purpose |
|----------|---------|---------|
| `ENABLE_INSTRUMENTATION` | `true` | Set to `false` to disable native instrumentation. See [Disabling instrumentation](#disabling-instrumentation) for the programmatic alternative with sticky semantics. |
| `ENABLE_SENSITIVE_DATA` | `false` | Set to `true` to emit sensitive data (prompts, responses, etc.). |
| `ENABLE_CONSOLE_EXPORTERS` | `false` | Set to `true` to add console exporters. Only used by `configure_otel_providers()`. |
| `VS_CODE_EXTENSION_PORT` | unset | Port used by the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) tracing integration. Only used by `configure_otel_providers()`. |
All of these are booleans and default to `false`.
You can also call `enable_sensitive_telemetry()` from `agent_framework.observability` to opt in to sensitive-data capture programmatically.
Finally we have `VS_CODE_EXTENSION_PORT` which you can set to a port, which can be used to setup the AI Toolkit for VS Code tracing integration. See [here](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) for more details.
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
The framework will emit observability data when the `ENABLE_INSTRUMENTATION` environment variable is set to `true`. If both are `true` then it will also emit sensitive information. When these are not set, or set to false, you can use the `enable_instrumentation()` function from the `agent_framework.observability` module to turn on instrumentation programmatically. This is useful when you want to control this via code instead of environment variables.
### Disabling instrumentation
> **Note**: Sensitive information includes prompts, responses, and more, and should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data.
There are two ways to turn Agent Framework's native instrumentation off, and they have **different scopes**:
The two other variables, `ENABLE_CONSOLE_EXPORTERS` and `VS_CODE_EXTENSION_PORT`, are used to configure where the observability data is sent. Those are only activated when calling `configure_otel_providers()`.
| Approach | Scope | Sticky? | When framework code calls `enable_instrumentation()` later, what happens? |
|----------|-------|---------|---------------------------------------------------------------------------|
| `ENABLE_INSTRUMENTATION=false` in the environment | Initial settings only | No | Instrumentation flips back **on**. |
| `disable_instrumentation()` called from code | Process-wide, sticky | Yes | Instrumentation **stays off** — the user-disable intent wins. |
If you want telemetry off **and want it to stay off**, use `disable_instrumentation()`.
#### Sticky semantics — why this matters
Framework integrations and third-party libraries can call `enable_instrumentation()`, `enable_sensitive_telemetry()`, or `configure_otel_providers()` as part of their own setup. For example, `FoundryChatClient.configure_azure_monitor()` calls `enable_instrumentation()` after wiring up Azure Monitor. That's normally what you want — but if **you** have explicitly opted out, you don't want any of those calls to silently re-enable telemetry.
`disable_instrumentation()` solves this by setting a **sticky** flag on `OBSERVABILITY_SETTINGS` that remains in effect until you explicitly clear it. While the flag is set:
1. `OBSERVABILITY_SETTINGS.enable_instrumentation` and `enable_sensitive_data` **read as `False`** regardless of the stored value.
2. `enable_instrumentation()` and `enable_sensitive_telemetry()` are **no-ops** and log an info-level message.
3. `configure_otel_providers()` still configures providers / exporters / views (so a later force-enable can use them), but does not flip instrumentation on.
4. Direct attribute writes like `OBSERVABILITY_SETTINGS.enable_instrumentation = True` from any code are **silently dropped** (defense in depth).
5. Integrations that consult `OBSERVABILITY_SETTINGS.is_user_disabled` (e.g. `FoundryChatClient.configure_azure_monitor()`, `FoundryAgent.configure_azure_monitor()`) **skip their setup entirely**, so global Azure Monitor providers aren't installed unnecessarily.
```python
from agent_framework.observability import disable_instrumentation
# After this call, Agent Framework expresses your intent to opt out of telemetry.
# Library and framework code is expected to honor that intent and not flip
# instrumentation back on (e.g. by calling `enable_instrumentation()`,
# `enable_sensitive_telemetry()`, or writing to public attributes on
# `OBSERVABILITY_SETTINGS`). The framework actively short-circuits the public
# enable paths so the user's intent stays leading. A determined caller can still
# pass `force=True` or mutate private (`_`-prefixed) attributes to bypass it,
# but those are out-of-contract escape hatches that should not be used by
# integrations on the user's behalf.
disable_instrumentation()
```
#### Forcing re-enablement after a disable
To intentionally re-enable telemetry after `disable_instrumentation()`, pass `force=True` to either of the two public enable helpers. This is the only way to clear the sticky disable, so the user's opt-out can only be reversed by a deliberate user opt-in:
```python
from agent_framework.observability import (
disable_instrumentation,
enable_instrumentation,
enable_sensitive_telemetry,
)
disable_instrumentation()
# Without force=True, these are no-ops while the disable is sticky:
enable_instrumentation() # logs info, does nothing
enable_sensitive_telemetry() # logs info, does nothing
# With force=True, the sticky disable is cleared and the call proceeds:
enable_instrumentation(force=True)
# or
enable_sensitive_telemetry(force=True)
# After a force-enable you can `disable_instrumentation()` again to re-arm
# the sticky disable.
```
#### Checking the disable state from integrations
If you're writing an integration that performs telemetry setup as a side effect (e.g. provisioning a third-party exporter), consult the public read-only `is_user_disabled` property and early-return when it's set:
```python
from agent_framework.observability import OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"Skipping telemetry setup because the user called disable_instrumentation()."
)
return
```
This is what the built-in `FoundryChatClient.configure_azure_monitor()` and `FoundryAgent.configure_azure_monitor()` do — so calling `disable_instrumentation()` reliably prevents Azure Monitor's global providers from being installed by those helpers.
#### What `disable_instrumentation()` does **not** do
- It does not tear down OpenTelemetry providers, exporters, or in-flight spans that were already set up before the disable call. It only gates **future** captures by Agent Framework code paths.
- It does not stop telemetry from third-party instrumentations (e.g. `azure-monitor-opentelemetry`'s system metrics) that are wired up outside Agent Framework. Configure those separately if needed.
- It does not persist across processes. Each Python process starts with the disable flag cleared; if you always want telemetry off in a given environment, set `ENABLE_INSTRUMENTATION=false` as an environment variable in addition to (or instead of) the programmatic call.
#### Environment variables for `configure_otel_providers()`
@@ -202,7 +304,8 @@ The `configure_otel_providers()` function automatically reads **standard OpenTel
> **Note**: These are standard OpenTelemetry environment variables. See the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for more details.
#### Logging
Use standard Python logging configuration to align logs with telemetry output.
Use standard Python logging configuration to align logs with telemetry output:
```python
import logging
@@ -212,15 +315,14 @@ logging.basicConfig(
datefmt="%Y-%m-%d %H:%M:%S",
)
```
You can control at what level logging happens and thus what logs get exported, you can do this, by adding this:
To control which logs are exported, adjust the root logger level — other loggers inherit from it by default:
```python
import logging
logger = logging.getLogger()
logger.setLevel(logging.NOTSET)
logging.getLogger().setLevel(logging.NOTSET)
```
This gets the root logger and sets the level of that, automatically other loggers inherit from that one, and you will get detailed logs in your telemetry.
## Samples
@@ -228,36 +330,35 @@ This folder contains different samples demonstrating how to use telemetry in var
| Sample | Description |
|--------|-------------|
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | **Recommended starting point**: Shows how to create custom exporters with specific configuration and pass them to `configure_otel_providers()`. Useful for advanced scenarios. |
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | Shows how to setup telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
| [agent_observability.py](./agent_observability.py) | Shows telemetry collection for an agentic application with tool calls using environment variables. |
| [foundry_tracing.py](./foundry_tracing.py) | Shows Azure Monitor integration with Foundry for any chat client. |
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: Shows manual setup of exporters and providers with console output. Useful for understanding how observability works under the hood. |
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: Shows zero-code telemetry setup using the `opentelemetry-enable_instrumentation` CLI tool. |
| [workflow_observability.py](./workflow_observability.py) | Shows telemetry collection for a workflow with multiple executors and message passing. |
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | **Recommended starting point**: configure telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | Create custom exporters with specific configuration and pass them to `configure_otel_providers()`. |
| [agent_observability.py](./agent_observability.py) | Telemetry collection for an agentic application with tool calls. |
| [foundry_tracing.py](./foundry_tracing.py) | Azure Monitor integration with Microsoft Foundry. |
| [workflow_observability.py](./workflow_observability.py) | Telemetry collection for a workflow with multiple executors and message passing. |
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: manual setup of exporters and providers with console output — useful for understanding how observability works under the hood. |
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: zero-code provider/exporter setup using the `opentelemetry-instrument` CLI wrapper. |
### Running the samples
1. Open a terminal and navigate to this folder: `python/samples/02-agents/observability/`. This is necessary for the `.env` file to be read correctly.
2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example).
> **Note**: You can start with just `ENABLE_INSTRUMENTATION=true` and add `OTEL_EXPORTER_OTLP_ENDPOINT` or other configuration as needed. If no exporters are configured, you can set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
3. Choose one environment-loading approach:
- **A. Sample-managed loading (current samples):** run from this folder so the sample's `load_dotenv()` call can find `.env`.
- **B. Shell/IDE-managed environment:** set/export environment variables directly, or use an IDE run configuration that injects env vars / `.env`.
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")` (or your own settings loader path).
- **D. CLI-managed env file:** run with `uv` and pass the file explicitly, for example:
`uv run --env-file=.env python configure_otel_providers_with_env_var.py`
4. Activate your python virtual environment, then run a sample (for example `python configure_otel_providers_with_env_var.py`).
1. Open a terminal in this folder (`python/samples/02-agents/observability/`) so that `.env` is found.
2. Create a `.env` file if you don't already have one. See [.env.example](./.env.example).
> Instrumentation is on by default. Set `OTEL_EXPORTER_OTLP_ENDPOINT` (or other configuration) as needed. With no exporters configured, set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
3. Pick an environment-loading approach:
- **A. Sample-managed:** run from this folder so the sample's `load_dotenv()` call can find `.env`.
- **B. Shell/IDE-managed:** export environment variables, or use an IDE run configuration that injects them.
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")`.
- **D. CLI-managed:** run with `uv` and pass the file explicitly, e.g. `uv run --env-file=.env python configure_otel_providers_with_env_var.py`.
4. Activate your virtual environment, then run a sample (e.g. `python configure_otel_providers_with_env_var.py`).
> If you do manual provider setup (e.g., Azure Monitor), call `enable_instrumentation()` to turn on Agent Framework telemetry code paths; if you want Agent Framework to configure exporters/providers for you, call `configure_otel_providers(...)`.
> If you set up providers manually (e.g. Azure Monitor), Agent Framework instrumentation is still on by default. Call `enable_sensitive_telemetry()` if you also want to capture sensitive data. To have Agent Framework configure exporters and providers for you, call `configure_otel_providers(...)`.
> Each sample will print the Operation/Trace ID, which can be used later for filtering logs and traces in Application Insights or Aspire Dashboard.
> Each sample prints its Operation/Trace ID, which you can use to filter logs and traces in Application Insights or the Aspire Dashboard.
# Appendix
## Azure Monitor Queries
When you are in Azure Monitor and want to have a overall view of the span, use this query in the logs section:
For an overall view of a span in Azure Monitor, run this query in the Logs section:
```kusto
dependencies
@@ -280,7 +381,8 @@ dependencies
```
### Grafana dashboards with Application Insights data
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
In addition to the native Application Insights UI, you can use Grafana to visualize the same telemetry data. Two tailored dashboards are available to get you started:
#### Agent Overview dashboard
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
@@ -292,117 +394,27 @@ Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
## Migration Guide
We've done a major update to the observability API in Agent Framework Python SDK. The new API simplifies configuration by relying more on standard OpenTelemetry environment variables and have split the instrumentation from the configuration.
Instrumentation is now **enabled by default** (you no longer have to opt in by calling `enable_instrumentation()` at startup), and the way you opt in to capturing sensitive payloads has its own dedicated function.
If you're updating from a previous version of the Agent Framework, here are the key changes to the observability API:
### Environment Variables
| Old Variable | New Variable | Notes |
|-------------|--------------|-------|
| `OTLP_ENDPOINT` | `OTEL_EXPORTER_OTLP_ENDPOINT` | Standard OpenTelemetry env var |
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | N/A | Use `configure_azure_monitor()` |
| N/A | `ENABLE_CONSOLE_EXPORTERS` | New opt-in flag for console output |
### OTLP Configuration
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
# Via parameter
setup_observability(otlp_endpoint="http://localhost:4317")
# Via environment variable
# OTLP_ENDPOINT=http://localhost:4317
setup_observability()
```
**After (Current):**
```python
from agent_framework.observability import configure_otel_providers
# Via standard OTEL environment variable (recommended)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
configure_otel_providers()
# Or via custom exporters
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
configure_otel_providers(exporters=[
OTLPSpanExporter(endpoint="http://localhost:4317"),
OTLPLogExporter(endpoint="http://localhost:4317"),
OTLPMetricExporter(endpoint="http://localhost:4317"),
])
```
### Azure Monitor Configuration
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
setup_observability(
applicationinsights_connection_string="InstrumentationKey=...",
applicationinsights_live_metrics=True,
)
```
**After (Current):**
If your code previously did:
```python
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import create_resource, enable_instrumentation
from azure.identity import AzureCliCredential
from azure.monitor.opentelemetry import configure_azure_monitor
from agent_framework.observability import enable_instrumentation
async def main():
# For Microsoft Foundry projects
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4o",
credential=AzureCliCredential(),
)
await client.configure_azure_monitor(enable_live_metrics=True)
# For non-Azure AI projects
configure_azure_monitor(
connection_string="InstrumentationKey=...",
resource=create_resource(),
enable_live_metrics=True,
)
enable_instrumentation()
enable_instrumentation(enable_sensitive_data=True)
```
### Console Output
replace it with:
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
# Console was used as automatic fallback
setup_observability() # Would output to console if no exporters configured
```
**After (Current):**
```python
from agent_framework.observability import configure_otel_providers
from agent_framework.observability import enable_sensitive_telemetry
# Console exporters are now opt-in
# ENABLE_CONSOLE_EXPORTERS=true
configure_otel_providers()
# Or programmatically
configure_otel_providers(enable_console_exporters=True)
enable_sensitive_telemetry()
```
### Benefits of New API
`enable_sensitive_telemetry()` ensures that instrumentation is on and turns sensitive-event capture on in one call. `enable_instrumentation()` still exists for the rare case where you want to programmatically force instrumentation on without enabling sensitive data (e.g. to override `ENABLE_INSTRUMENTATION=false`), and it now also accepts `force=True` to clear a previous `disable_instrumentation()` — see [Disabling instrumentation](#disabling-instrumentation).
1. **Standards Compliant**: Uses standard OpenTelemetry environment variables
2. **Simpler**: Less configuration needed, more relies on environment
3. **Flexible**: Easy to add custom exporters alongside environment-based ones
4. **Cleaner Separation**: Azure Monitor setup is in Azure-specific client
5. **Better Compatibility**: Works with any OTEL-compatible tool (Jaeger, Zipkin, Prometheus, etc.)
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
## Aspire Dashboard
@@ -437,7 +449,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
Or set it as an environment variable when running your samples:
```bash
ENABLE_INSTRUMENTATION=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
```
### Viewing telemetry data
@@ -7,7 +7,7 @@ from typing import Annotated
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry._logs import set_logger_provider
@@ -135,7 +135,8 @@ async def main():
setup_logging()
setup_tracing()
setup_metrics()
enable_instrumentation()
# Instrumentation is enabled by default; call this to also capture sensitive data.
enable_sensitive_telemetry()
await run_chat_client()
@@ -19,13 +19,20 @@ if TYPE_CHECKING:
"""
This sample shows how you can configure observability of an application with zero code changes.
It relies on the OpenTelemetry auto-instrumentation capabilities, and the observability setup
is done via environment variables.
Follow the install guidance from https://opentelemetry.io/docs/zero-code/python/ to install the OpenTelemetry CLI tool,
when using `uv` there are some additional steps, so follow the instructions carefully.
Agent Framework is natively instrumented with OpenTelemetry, so no auto-instrumentation of the
framework itself is required. Running the `opentelemetry-instrument` CLI wrapper simply configures
the global tracer/meter providers and exporters from environment variables (or CLI flags) at
process startup, so the application code does not need to set them up explicitly. The native
spans/metrics emitted by Agent Framework are then picked up by that globally configured pipeline.
And setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update the endpoint below).
See: https://opentelemetry.io/docs/zero-code/python/
Install the OpenTelemetry CLI tool following the guidance above (when using `uv` there are some
additional steps, so follow the instructions carefully).
Then setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update
the endpoint below).
Then you can run:
```bash
@@ -0,0 +1,40 @@
# Monty local code interpreter
Demonstrates the standalone [Monty](https://github.com/pydantic/monty)
`MontyExecuteCodeTool` — a sandboxed local code interpreter that the agent can
invoke directly. Two patterns are shown:
| File | Pattern |
|------|---------|
| [`monty_code_interpreter.py`](monty_code_interpreter.py) | **Standalone tool** — `MontyExecuteCodeTool` is added to the agent tool list and self-describes its sandbox tools, so no extra agent instructions are needed. Best for quick prototyping. |
| [`monty_code_interpreter_manual_wiring.py`](monty_code_interpreter_manual_wiring.py) | **Manual static wiring** — sandbox tools and CodeAct instructions are built once and passed to the `Agent` constructor alongside a direct-only tool (`send_email`). Best when the tool set is fixed for the agent's lifetime. |
For the recommended provider-driven pattern (with dynamic tool / capability
management), see
[`../../context_providers/code_act/`](../../context_providers/code_act/).
## Installation
```bash
pip install agent-framework agent-framework-monty --pre
```
> `agent-framework-monty` is an alpha package and is not yet part of
> `agent-framework[all]`. The `--pre` flag is required.
>
> Monty is cross-platform and has no hypervisor/WASM backend dependency.
> Inside the sandbox, OS / filesystem / network calls are blocked
> (`PermissionError`); registered host tools retain full Python access.
## Prerequisites
- An Azure AI Foundry project endpoint (`FOUNDRY_PROJECT_ENDPOINT`)
- A deployed model (`FOUNDRY_MODEL`)
- Azure CLI authenticated (`az login`)
## Run
```bash
python monty_code_interpreter.py
python monty_code_interpreter_manual_wiring.py
```
@@ -0,0 +1,114 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import os
from typing import Annotated, Any, Literal
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_monty import MontyExecuteCodeTool
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
"""This sample demonstrates the standalone Monty execute_code tool.
The sample adds `MontyExecuteCodeTool` directly to the agent. The tool's own
description advertises the registered sandbox tools (as typed async functions
and via `call_tool(...)`) plus the Monty DSL, so no extra CodeAct-specific
agent instructions are required.
Note: `agent-framework-monty` is an alpha package and is not yet part of
`agent-framework[all]`. Install it explicitly with:
pip install agent-framework agent-framework-monty --pre
"""
load_dotenv()
@tool(approval_mode="never_require")
def compute(
operation: Annotated[
Literal["add", "subtract", "multiply", "divide"],
"Math operation: add, subtract, multiply, or divide.",
],
a: Annotated[float, "First numeric operand."],
b: Annotated[float, "Second numeric operand."],
) -> float:
"""Perform a math operation used by sandboxed code."""
operations = {
"add": a + b,
"subtract": a - b,
"multiply": a * b,
"divide": a / b if b else float("inf"),
}
return operations[operation]
@tool(approval_mode="never_require")
def fetch_data(
table: Annotated[str, "Name of the simulated table to query."],
) -> list[dict[str, Any]]:
"""Fetch simulated records from a named table."""
data: dict[str, list[dict[str, Any]]] = {
"users": [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Charlie", "role": "admin"},
],
"products": [
{"id": 101, "name": "Widget", "price": 9.99},
{"id": 102, "name": "Gadget", "price": 19.99},
],
}
return data.get(table, [])
async def main() -> None:
"""Run the standalone Monty execute_code sample."""
# 1. Create the packaged execute_code tool and register sandbox tools on it.
execute_code = MontyExecuteCodeTool(
tools=[compute, fetch_data],
approval_mode="never_require",
)
# 2. Create the client and the agent.
agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="MontyExecuteCodeToolAgent",
instructions="You are a helpful assistant.",
tools=execute_code,
)
# 3. Run one request through the direct-tool surface.
print("=" * 60)
print("Monty execute_code tool sample")
print("=" * 60)
query = (
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
"and multiplication result. Use one execute_code call."
)
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
"""
Sample output (shape only):
============================================================
Monty execute_code tool sample
============================================================
User: Fetch all users, find admins, multiply 6*7, ...
Agent: ...
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,136 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import os
from typing import Annotated, Any, Literal
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_monty import MontyExecuteCodeTool
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
"""This sample demonstrates manual static wiring of Monty CodeAct without a provider.
Instead of using `MontyCodeActProvider` with `context_providers=`, this sample
creates a `MontyExecuteCodeTool` directly, extracts its CodeAct instructions
once, and passes both to the `Agent` constructor at build time.
This avoids the per-run provider lifecycle (`before_run` / `after_run`) and is
well-suited when the tool registry is fixed for the agent's lifetime. The
tradeoff is that dynamic tool changes between runs are not supported - any
mutations to the tool would not update the agent's instructions automatically.
Note: `agent-framework-monty` is an alpha package and is not yet part of
`agent-framework[all]`. Install it explicitly with:
pip install agent-framework agent-framework-monty --pre
"""
load_dotenv()
@tool(approval_mode="never_require")
def compute(
operation: Annotated[
Literal["add", "subtract", "multiply", "divide"],
"Math operation: add, subtract, multiply, or divide.",
],
a: Annotated[float, "First numeric operand."],
b: Annotated[float, "Second numeric operand."],
) -> float:
"""Perform a math operation used by sandboxed code."""
operations = {
"add": a + b,
"subtract": a - b,
"multiply": a * b,
"divide": a / b if b else float("inf"),
}
return operations[operation]
@tool(approval_mode="never_require")
def fetch_data(
table: Annotated[str, "Name of the simulated table to query."],
) -> list[dict[str, Any]]:
"""Fetch simulated records from a named table."""
data: dict[str, list[dict[str, Any]]] = {
"users": [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Charlie", "role": "admin"},
],
"products": [
{"id": 101, "name": "Widget", "price": 9.99},
{"id": 102, "name": "Gadget", "price": 19.99},
],
}
return data.get(table, [])
@tool(approval_mode="never_require")
def send_email(
to: Annotated[str, "Recipient email address."],
subject: Annotated[str, "Email subject line."],
body: Annotated[str, "Email body text."],
) -> str:
"""Simulate sending an email (direct-only tool, not available inside the sandbox)."""
return f"Email sent to {to}: {subject}"
async def main() -> None:
"""Run the manual static-wiring Monty sample."""
# 1. Create the execute_code tool and register sandbox tools on it.
execute_code = MontyExecuteCodeTool(
tools=[compute, fetch_data],
approval_mode="never_require",
)
# 2. Build CodeAct instructions once. Setting tools_visible_to_model=False
# tells the instructions builder that sandbox tools are not in the agent's
# direct tool list, so the model must call them inside execute_code.
codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False)
# 3. Create the client and the agent with everything wired at construction time.
# - send_email is a direct-only tool (not available inside the sandbox).
# - execute_code carries sandbox tools (compute, fetch_data) for Monty.
agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="MontyManualWiringAgent",
instructions=f"You are a helpful assistant.\n\n{codeact_instructions}",
tools=[send_email, execute_code],
)
# 4. Run a request that exercises both the sandbox and the direct tool.
print("=" * 60)
print("Manual static-wiring Monty CodeAct sample")
print("=" * 60)
query = (
"Fetch all users, find admins, multiply 6*7, and print the users, admins, "
"and multiplication result. Use one execute_code call. "
"Then send an email to admin@example.com summarising the results."
)
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
"""
Sample output (shape only):
============================================================
Manual static-wiring Monty CodeAct sample
============================================================
User: Fetch all users, find admins, multiply 6*7, ...
Agent: ...
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -22,9 +22,6 @@ What this example shows:
- executor_completed events (type='executor_completed') contain the messages sent via ctx.send_message() in event.data
- How to generically observe all executor I/O through workflow streaming events
This approach allows you to enable_instrumentation any workflow for observability without
changing the executor implementations.
Prerequisites:
- No external services required.
"""
@@ -18,7 +18,8 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
| 8 | [Azure AI Search RAG](responses/08_azure_search_rag/) | An agent with Retrieval Augmented Generation (RAG) capabilities backed by Azure AI Search, grounding answers in documents indexed in a pre-provisioned search index. |
| 9 | [Foundry Skills](responses/09_foundry_skills/) | An agent that uploads `SKILL.md` files to the Foundry Skills REST API and downloads them at startup, decoupling tone/policy guidelines from agent code. |
| 10 | [Foundry Memory](responses/10_foundry_memory/) | An agent with persistent semantic memory backed by an Azure AI Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. |
| 11 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
| 11 | [Monty CodeAct](responses/11_monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the alpha `agent-framework-monty` package. |
| 12 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
### Invocations API
@@ -1,5 +1,7 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . user_agent/
@@ -10,6 +10,20 @@ You can also create a Foundry Toolbox in the Foundry portal. Read more about it
> If you set up a project with this sample and provision the resources using `azd provision`, a Foundry Toolbox will be created with the specified tools in [`agent.manifest.yaml`](agent.manifest.yaml).
### Authentication Methods
You can connect to MCP servers in Foundry Toolbox that use different authentication methods. This sample demonstrates the following authentication methods:
- **No authentication**: The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs`
- **Key-based authentication**: The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication.
- **OAuth2 authentication (managed)**: The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication.
- **Agent identity authentication**: The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` (Azure Language MCP server) with agent identity for authentication.
- **Entra Pass-through authentication**: The tool requires an Entra pass-through token to authenticate. Sample MCP server: Microsoft Outlook MCP server with Entra pass-through for authentication.
> Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample.
There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md).
## How It Works
### Model Integration
@@ -31,20 +45,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
```bash
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
Or in PowerShell:
```powershell
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
The agent derives the endpoint at runtime as:
```
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
```
When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml).
@@ -18,16 +18,92 @@ template:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME
value: "agent-tools"
value: "agent-tools-2"
# parameters:
# properties:
# - name: mcp_endpoint
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest
# secret: false
# description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
# - name: github_pat
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
# # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
# # instead, you can leave this empty.
# secret: true
# description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
# - name: language_mcp_entra_audience
# secret: false
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
# - name: language_mcp_target_url
# secret: false
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
# - name: outlook_mail_entra_audience
# secret: false
# description: Entra ID audience for the Outlook Mail MCP server
# - name: outlook_mail_entra_mcp_target
# secret: false
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
- kind: toolbox
name: agent-tools
tools:
- type: web_search
name: web_search
- type: code_interpreter
name: code_interpreter
# - kind: connection
# # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server
# name: github-mcp-pat-conn
# category: RemoteTool
# authType: CustomKeys
# target: https://api.githubcopilot.com/mcp
# credentials:
# type: CustomKeys
# keys:
# Authorization: "Bearer {{ github_pat }}"
# - kind: connection
# # A connection that uses OAuth2 to authenticate with the GitHub MCP server
# name: github-mcp-oauth-conn
# category: RemoteTool
# authType: OAuth2
# target: https://api.githubcopilot.com/mcp
# connectorName: foundrygithubmcp
# credentials:
# type: OAuth2
# clientId: managed
# clientSecret: managed
# - kind: connection
# name: language-mcp-conn
# category: RemoteTool
# authType: AgenticIdentity
# audience: "{{ language_mcp_entra_audience }}"
# target: "{{ language_mcp_target_url }}"
# # - kind: connection
# # name: outlook-mail-conn
# # category: RemoteTool
# # authType: UserEntraToken
# # audience: "{{ outlook_mail_entra_audience }}"
# # target: "{{ outlook_mail_entra_mcp_target }}"
# - kind: toolbox
# name: agent-tools
# tools:
# - type: web_search
# name: web_search
# - type: code_interpreter
# name: code_interpreter
# # - type: mcp
# # # This MCP tool doesn't require authentication
# # server_label: noauth_mcp
# # server_url: "{{ mcp_endpoint }}"
# # require_approval: "never"
# - type: mcp
# # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2
# server_label: github
# project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication
# require_approval: "never"
# - type: mcp
# # This MCP tool uses the Azure Language MCP server with agent identity for authentication
# server_label: language-mcp
# project_connection_id: language-mcp-conn
# require_approval: "never"
# # - type: mcp
# # server_label: outlook-mail
# # project_connection_id: outlook-mail-conn
# # require_approval: "never"
@@ -3,12 +3,11 @@
import asyncio
import os
from collections.abc import Callable
from typing import Any
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.core.credentials import TokenCredential
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from dotenv import load_dotenv
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
load_dotenv()
def _resolve_toolbox_endpoint() -> str:
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
@@ -29,47 +28,61 @@ def _resolve_toolbox_endpoint() -> str:
return endpoint
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
return {
"Authorization": f"Bearer {get_token()}",
}
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
return provide
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
async def main():
credential = DefaultAzureCredential()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
toolbox_tool = MCPStreamableHTTPTool(
name="foundry_toolbox",
description="Tools exposed by the configured Foundry toolbox",
url=_resolve_toolbox_endpoint(),
header_provider=make_toolbox_header_provider(credential),
load_prompts=False,
)
# Resolve the endpoint once and derive the tool name from the same source: when
# ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the
# tool's local name and the upstream toolbox always agree.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
headers={"Foundry-Features": "Toolboxes=V1Preview"},
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
async with Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox_tool,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
) as agent:
server = ResponsesHostServer(agent)
await server.run_async()
@@ -1,2 +1,4 @@
agent-framework
agent-framework-foundry-hosting
# agent-framework
# agent-framework-foundry-hosting
mcp>=1.24.0,<2
@@ -21,9 +21,10 @@ This agent uses four tools:
1. **Get Current Working Directory Tool (`get_cwd`)** – Returns the current working directory of the agent host process.
2. **List Files Tool (`list_files`)** – Lists the files in a specified directory.
3. **Read File Tool (`read_file`)** – Reads the contents of a specified file.
4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe.
4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe sandboxed environment.
5. **Web Search Tool (`web_search`)** – Allows the agent to perform web searches using the Bing Search API.
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool is a managed tool provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool and web search tool are managed tools provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
## Running the Agent Host
@@ -34,20 +35,20 @@ An extra environment variable must be set to point to the toolbox MCP endpoint.
**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development):
```bash
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
export FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
Or in PowerShell:
```powershell
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=v1"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1"
```
**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`):
The agent derives the endpoint at runtime as:
```
{FOUNDRY_PROJECT_ENDPOINT}/toolsets/{TOOLBOX_NAME}/mcp?api-version=v1
{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1
```
When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml).
@@ -3,12 +3,11 @@
import asyncio
import os
from collections.abc import Callable
from typing import Any
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.core.credentials import TokenCredential
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from dotenv import load_dotenv
@@ -16,7 +15,7 @@ from dotenv import load_dotenv
load_dotenv()
def _resolve_toolbox_endpoint() -> str:
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``FOUNDRY_TOOLBOX_ENDPOINT`` env var; falls back to
@@ -29,19 +28,18 @@ def _resolve_toolbox_endpoint() -> str:
return endpoint
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
return f"{project_endpoint}/toolsets/{toolbox_name}/mcp?api-version=v1"
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]:
"""Build a header_provider that injects a fresh Azure AI bearer token on every MCP request."""
get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def provide(_kwargs: dict[str, Any]) -> dict[str, str]:
return {
"Authorization": f"Bearer {get_token()}",
}
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
return provide
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
@tool(description="Get the current working directory.", approval_mode="never_require")
@@ -75,39 +73,47 @@ def read_file(file_path: str) -> str:
async def main():
credential = DefaultAzureCredential()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
# Connect to the toolbox MCP endpoint and expose only the code_interpreter tool.
# The toolbox deployed has two tools: (see agent.manifest.yaml)
# - `code_interpreter`
# - `web_search`
# We only need the `code_interpreter` tool for this sample.
toolbox_tool = MCPStreamableHTTPTool(
name="foundry_toolbox",
description="Tools exposed by the configured Foundry toolbox",
url=_resolve_toolbox_endpoint(),
header_provider=make_toolbox_header_provider(credential),
load_prompts=False,
allowed_tools=["code_interpreter"],
)
# Resolve the endpoint once and derive the tool name from the same source: when
# ``TOOLBOX_NAME`` isn't explicitly set, parse it out of the resolved URL so the
# tool's local name and the upstream toolbox always agree.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or toolbox_endpoint.rsplit("/mcp", 1)[0].rsplit("/", 1)[-1]
async with Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox_tool],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
) as agent:
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
headers={"Foundry-Features": "Toolboxes=V1Preview"},
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions=(
"You are a friendly assistant. Keep your answers brief. "
"Make sure all mathematical calculations are performed using the code interpreter "
"instead of mental arithmetic."
),
tools=[get_cwd, list_files, read_file, toolbox],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
@@ -1,4 +1,3 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
ENABLE_INSTRUMENTATION=true
ENABLE_SENSITIVE_DATA=true
@@ -16,7 +16,7 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age
### Instrumentation
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution, but it's turned off by default. This sample demonstrates how to enable instrumentation via environment variables in `agent.manifest.yaml` and `agent.yaml`. The relevant environment variables are `ENABLE_INSTRUMENTATION` and `ENABLE_SENSITIVE_DATA`, which can be set to `true` to enable diagnostics and capture sensitive events respectively.
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution. Instrumentation is enabled by default. To also capture sensitive event payloads (prompts, tool arguments, etc.) set `ENABLE_SENSITIVE_DATA=true`. This sample demonstrates how to manage these settings via environment variables in `agent.manifest.yaml` and `agent.yaml`.
Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment.
@@ -17,8 +17,6 @@ template:
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
resources:
@@ -5,12 +5,10 @@ protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
cpu: "0.25"
memory: "0.5Gi"
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
value: true
@@ -0,0 +1,25 @@
FROM python:3.12-slim
# Bring in the `uv` binary from a pinned Astral image. Update this tag intentionally;
# `latest` would make rebuilds non-deterministic.
COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /usr/local/bin/
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
# Sync dependencies first to maximize Docker layer caching.
COPY pyproject.toml ./
RUN uv sync --no-install-project --no-cache
# Now copy the rest of the agent and finalize the environment.
COPY . ./
RUN uv sync --no-cache
EXPOSE 8088
CMD ["uv", "run", "--no-sync", "python", "main.py"]
@@ -0,0 +1,116 @@
# What this sample demonstrates
An [Agent Framework](https://github.com/microsoft/agent-framework) agent with a
**Monty-backed CodeAct context provider** hosted using the **Responses protocol**.
The model receives one tool (`execute_code`) and runs Python inside a
[Monty](https://github.com/pydantic/monty) interpreter; the registered host
tools (`compute`, `fetch_data`) are only reachable from inside the sandbox via
typed `await compute(...)` calls or the generic `call_tool(...)` fallback.
> [!NOTE]
> `agent-framework-monty` is an **alpha** package, so the `pyproject.toml`
> sets `[tool.uv] prerelease = "allow"` to let `uv sync` pick up the
> `1.0.0a*` release from PyPI.
## How It Works
### Model Integration
The agent uses `FoundryChatClient` to create a Responses client from the project
endpoint and the model deployment. The agent supports both streaming (SSE
events) and non-streaming (JSON) response modes.
See [main.py](main.py) for the full implementation.
### CodeAct context provider
`MontyCodeActProvider` is added to the agent via `context_providers=[...]`. On
every run it injects:
- An `execute_code` tool that runs Python in the Monty interpreter.
- Dynamic CodeAct instructions describing the available host tools and DSL.
The host tools (`compute`, `fetch_data`) are **not** exposed as direct agent
tools — the model can only call them from inside `execute_code`, either as
typed async functions (`await compute(operation="multiply", a=6, b=7)`) or via
the generic `call_tool("compute", operation="multiply", a=6, b=7)` fallback.
Code is type-checked against the host tool signatures using
[ty](https://docs.astral.sh/ty/) before any tool runs.
OS-level access (filesystem, network, subprocess) is blocked inside the
sandbox; the registered host tools retain full Python access.
### Observability
Agent Framework's [native OpenTelemetry instrumentation](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) is enabled by setting these env vars in `agent.yaml` / `agent.manifest.yaml`:
- `ENABLE_INSTRUMENTATION=true` — turns on the framework's span/metric/log emitters.
- `ENABLE_SENSITIVE_DATA=true` — includes prompts, tool inputs, tool outputs, and completions in telemetry. **Dev/test only.**
`main.py` wires Azure Monitor at startup:
1. Reads `APPLICATIONINSIGHTS_CONNECTION_STRING` (Foundry hosting injects this automatically for the project's attached Application Insights resource; set it yourself when running locally).
2. Calls `azure.monitor.opentelemetry.configure_azure_monitor(connection_string=...)` to register Azure Monitor exporters with the global OTel tracer/meter/logger providers.
3. Calls `agent_framework.observability.enable_instrumentation()` so Agent Framework emits its `invoke_agent`, `chat`, `execute_tool`, and `execute_code` spans on those providers.
Trace linking happens automatically: the Foundry hosting layer's incoming `Responses` request becomes the **parent span**, and every framework / tool span (including the `execute_code` invocation that runs Monty) becomes a child via OpenTelemetry context propagation since both layers share the same global tracer provider. In Application Insights you can click any operation and see the full tree from inbound HTTP all the way down to individual `compute(...)` / `fetch_data(...)` calls inside the Monty sandbox.
## Running the Agent Host
This sample uses `pyproject.toml` + `uv sync` rather than the parent
README's `requirements.txt` flow. To run locally:
1. Install dependencies into a local virtual environment:
```bash
uv sync
```
2. Set the environment variables described in the
[parent README](../../README.md#running-the-agent-host-locally) (Foundry
project endpoint, model deployment, optional Application Insights), then
start the host:
```bash
uv run python main.py
```
Refer to the parent README for the shared `azd` / Docker / invocation /
deployment guidance.
## Interacting with the agent
> Depending on how you run the agent host, you can invoke the agent using
> `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the
> [parent README](../../README.md) for more details. Use this README for
> sample queries you can send to the agent.
Send a POST request to the server with a JSON body containing an `"input"`
field. Try queries that benefit from combining Python with multiple tool calls:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Fetch all users, find the admins, then multiply the count by 7. Use a single execute_code call."}'
```
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Compute the total price for one of every product in the products table. Use execute_code."}'
```
The model should respond with one `execute_code` call whose code looks like:
```python
users = await fetch_data(table="users")
admins = [u for u in users if u["role"] == "admin"]
result = await compute(operation="multiply", a=len(admins), b=7)
print(result)
```
## Deploying the Agent to Foundry
To host the agent on Foundry, follow the instructions in the
[Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry)
section of the README in the parent directory.
@@ -0,0 +1,28 @@
name: agent-framework-agent-monty-codeact-responses
description: >
An Agent Framework agent with a Monty-backed CodeAct context provider hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- CodeAct
- Monty
template:
name: agent-framework-agent-monty-codeact-responses
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: ENABLE_INSTRUMENTATION
value: "true"
- name: ENABLE_SENSITIVE_DATA
value: "true"
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,15 @@
kind: hosted
name: agent-framework-agent-monty-codeact-responses
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: ENABLE_INSTRUMENTATION
value: "true"
- name: ENABLE_SENSITIVE_DATA
value: "true"
@@ -0,0 +1,136 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import os
from typing import Annotated, Any, Literal
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import enable_instrumentation
from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_monty import MontyCodeActProvider
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from pydantic import Field
# Load environment variables from .env file (no-op when injected by Foundry).
load_dotenv()
logger = logging.getLogger(__name__)
def _setup_telemetry() -> None:
"""Wire Agent Framework spans to the Application Insights resource attached to the Foundry project.
Foundry-hosted runtimes inject ``APPLICATIONINSIGHTS_CONNECTION_STRING`` automatically;
locally you can set it yourself (see README). When the connection string is present we
configure Azure Monitor OTel exporters once and then flip the framework's instrumentation
flag so it emits ``invoke_agent`` / ``chat`` / ``execute_tool`` spans. The hosting layer's
incoming-request span becomes the parent automatically via OpenTelemetry context
propagation when both layers share the same global tracer provider.
"""
connection_string = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
if not connection_string:
logger.info(
"APPLICATIONINSIGHTS_CONNECTION_STRING is not set; Agent Framework spans will not "
"be exported to Azure Monitor. Set the env var to enable telemetry."
)
return
try:
from azure.monitor.opentelemetry import configure_azure_monitor
except ImportError:
logger.warning(
"azure-monitor-opentelemetry is not installed; skipping Azure Monitor setup. "
"Install it to export telemetry."
)
return
# Configure the global OTel providers (tracer/meter/logger) to export to Azure Monitor.
# Idempotent for repeated imports because we only call it from this entry point.
configure_azure_monitor(connection_string=connection_string)
# Flip the Agent Framework instrumentation flag so its spans are actually emitted on
# the now-configured global providers.
enable_instrumentation()
logger.info("Azure Monitor + Agent Framework instrumentation enabled.")
@tool(approval_mode="never_require")
def compute(
operation: Annotated[
Literal["add", "subtract", "multiply", "divide"],
Field(description="Math operation: add, subtract, multiply, or divide."),
],
a: Annotated[float, Field(description="First numeric operand.")],
b: Annotated[float, Field(description="Second numeric operand.")],
) -> float:
"""Perform a math operation used by sandboxed code."""
operations = {
"add": a + b,
"subtract": a - b,
"multiply": a * b,
"divide": a / b if b else float("inf"),
}
return operations[operation]
@tool(approval_mode="never_require")
def fetch_data(
table: Annotated[str, Field(description="Name of the simulated table to query.")],
) -> list[dict[str, Any]]:
"""Fetch simulated records from a named table."""
data: dict[str, list[dict[str, Any]]] = {
"users": [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "user"},
{"id": 3, "name": "Charlie", "role": "admin"},
],
"products": [
{"id": 101, "name": "Widget", "price": 9.99},
{"id": 102, "name": "Gadget", "price": 19.99},
],
}
return data.get(table, [])
def main() -> None:
"""Host a Monty CodeAct agent over the Responses protocol."""
# Set up telemetry BEFORE building the client/agent so the framework picks up
# the configured tracer provider when it lazily wires instrumentation.
_setup_telemetry()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
# MontyCodeActProvider injects a sandboxed `execute_code` tool into every
# agent run, plus dynamic instructions describing the registered host tools.
# The host tools are hidden from the model - they can only be invoked from
# inside the sandbox (`await compute(...)` or `call_tool(...)`).
codeact = MontyCodeActProvider(
tools=[compute, fetch_data],
approval_mode="never_require",
)
agent = Agent(
client=client,
instructions=(
"You are a friendly assistant. Use `execute_code` to combine "
"Python control flow with the provided host tools whenever the "
"task requires lookups, transformations, or computation."
),
context_providers=[codeact],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
[project]
name = "agent-framework-agent-monty-codeact-responses"
version = "0.1.0"
description = "Foundry-hosted Agent Framework agent with a Monty-backed CodeAct context provider."
requires-python = ">=3.12,<3.14"
dependencies = [
"agent-framework-foundry",
"agent-framework-foundry-hosting",
# agent-framework-monty is an alpha (1.0.0a*) release on PyPI.
"agent-framework-monty",
# Azure Monitor OpenTelemetry exporter; used to send agent telemetry to the
# Application Insights instance attached to the Foundry project.
"azure-monitor-opentelemetry",
]
[tool.uv]
# `agent-framework-monty` is an alpha package; allow the prerelease resolver
# to pick up 1.0.0a* releases from PyPI.
prerelease = "allow"
+16 -16
View File
@@ -90,7 +90,7 @@ Example values below are illustrative. For entries not backed by a single public
column names the closest public surface, helper, or package-level initialization point that reads the
variable.
| package | class | env var | example value |
| package | class/module | env var | example value |
| --- | --- | --- | --- |
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` |
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` |
@@ -117,21 +117,21 @@ variable.
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__SCHEMANAME` | `cr123_agentname` |
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__TENANTID` | `11111111-1111-1111-1111-111111111111` |
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__AGENTAPPID` | `22222222-2222-2222-2222-222222222222` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_INSTRUMENTATION` | `true` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_SENSITIVE_DATA` | `false` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_NAME` | `sample-agent` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_VERSION` | `1.0.0` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
| `agent-framework-core` | `observability` | `ENABLE_INSTRUMENTATION` | `true` |
| `agent-framework-core` | `observability` | `ENABLE_SENSITIVE_DATA` | `false` |
| `agent-framework-core` | `observability` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
| `agent-framework-core` | `observability` | `OTEL_SERVICE_NAME` | `sample-agent` |
| `agent-framework-core` | `observability` | `OTEL_SERVICE_VERSION` | `1.0.0` |
| `agent-framework-core` | `observability` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
| `agent-framework-devui` | `DevUI server` | `DEVUI_AUTH_TOKEN` | `my-devui-token` |
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` |
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_MODEL` | `gpt-4o` |
+89 -2
View File
@@ -50,6 +50,7 @@ members = [
"agent-framework-hyperlight",
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-monty",
"agent-framework-ollama",
"agent-framework-openai",
"agent-framework-orchestrations",
@@ -178,7 +179,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0rc1"
version = "1.0.0rc2"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -602,7 +603,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
]
[[package]]
@@ -720,6 +721,21 @@ requires-dist = [
{ name = "mem0ai", specifier = ">=1.0.0,<2" },
]
[[package]]
name = "agent-framework-monty"
version = "1.0.0a260518"
source = { editable = "packages/monty" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "pydantic-monty", specifier = ">=0,<0.1" },
]
[[package]]
name = "agent-framework-ollama"
version = "1.0.0b260519"
@@ -5737,6 +5753,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pydantic-monty"
version = "0.0.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/f8/431ba0b79d02922811392c4e3d283d6508f7052ceaa1936cc34878703ecc/pydantic_monty-0.0.17.tar.gz", hash = "sha256:9c4904a8fbc63282793f3afd2d180124494c7fc371783f365e5691c9586360af", size = 1007724, upload-time = "2026-04-22T20:13:48.915Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/19/8105bc0b3acb42f6cb48a29669a5e21316bc05e3e9b6fab64cf94b483712/pydantic_monty-0.0.17-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3c3b6c026d8a0437eeb4d6b2d908be75e2715e0555b9a13f076b7e9ba9bbae19", size = 7344730, upload-time = "2026-04-22T20:13:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a2/7281cdb37481c4252292b63bebf737c87d0fd463f3174499608607de0907/pydantic_monty-0.0.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c80b4d34437abd209c042f81f8ecea81a097022fb9b01431ab859b877edfbc4d", size = 7334937, upload-time = "2026-04-22T20:15:06.923Z" },
{ url = "https://files.pythonhosted.org/packages/a5/68/0bf7c0c627a56d8653b42888a3c1fc33cd33d2532ec456d9358275d7c792/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:beecc1f7e5b10db40d7b2b24a68166a36514289a2402bfef370a7984e90a2ab8", size = 7864543, upload-time = "2026-04-22T20:14:46.273Z" },
{ url = "https://files.pythonhosted.org/packages/09/9b/5a6f006541fd3bdc64b6dfbbaeabfb2244c89a22d7077a1fc92ec497c03e/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64ea7babdcc9fba93089fa52589b6d0549f755e37500f6cf4aeaeb8e56328a3e", size = 7138764, upload-time = "2026-04-22T20:15:30.516Z" },
{ url = "https://files.pythonhosted.org/packages/01/cc/59cca979bd427d166df8c827fba9e794c4a5c08943e225a22adf9854a78f/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a7fe77a191205becb622eaecb075e8bcbbbe4dac20a916d9c58ce6d59a22a8da", size = 7444006, upload-time = "2026-04-22T20:15:23.386Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c5/d027170fb33fcbc038febb76dfd2d9047f5194a250ea608e3ed8e5ec28d4/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cdbefc180cc83c8b8415aaf95b9099bb2cb15261f40ebe2c92f13e7d52439a4", size = 7967564, upload-time = "2026-04-22T20:14:57.315Z" },
{ url = "https://files.pythonhosted.org/packages/3e/01/ac0d4bc1ff00acfac14b7cb2ee322d08778c206cd57f43da8206a2f6ce78/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:575ce5aa31db18bbbf6275f00e9b0c005ca393bfb73a2f306a577ad490ec2d98", size = 8199021, upload-time = "2026-04-22T20:15:14.488Z" },
{ url = "https://files.pythonhosted.org/packages/51/85/8d0c6e5f127da9ebc0fcda6e411592d12b7606347d67aecd4363df5eed6b/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e252ec54fc4728406045f7be36ca45dbea8e6856df9c6154b1b9821b8952dfa2", size = 7769814, upload-time = "2026-04-22T20:14:55.197Z" },
{ url = "https://files.pythonhosted.org/packages/ac/cc/cb4d1b14b039eab00b33a7274f15f81739c3f272e2dfbeb8fb13c6b0c85d/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fba71e5cb49f15a1446ecee142c8cc11f4bd6df4fcb4926465c83181474b2fd4", size = 7317432, upload-time = "2026-04-22T20:14:19.993Z" },
{ url = "https://files.pythonhosted.org/packages/c8/16/737c7a023abbcb21848eb4d58f7167d9f4f8cdc46858ce8ed835cc2c137c/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69136647abd56f804987834e37573adcc5c3b3d05013b8b3a2939f44b3bd5199", size = 7767816, upload-time = "2026-04-22T20:13:40.002Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9f/5302b784f882ae8a8396f29f8c5ab4c16524c173a3d777b94af33858fdf2/pydantic_monty-0.0.17-cp310-cp310-win32.whl", hash = "sha256:d5b3beb6169b59adea10fdefb1e54bfa9a66165404891dfb6fcf16f7749cda3b", size = 7230648, upload-time = "2026-04-22T20:14:27.03Z" },
{ url = "https://files.pythonhosted.org/packages/1c/27/8c219f619dad466ec25db365acf88e2a50450dd862e0daff0eb281b6176b/pydantic_monty-0.0.17-cp310-cp310-win_amd64.whl", hash = "sha256:50ed9561b6dd1a1863d4cac81e4eaca64cb10ab541aaab92fcb5996739bb8e7f", size = 8075073, upload-time = "2026-04-22T20:14:17.073Z" },
{ url = "https://files.pythonhosted.org/packages/e7/42/ca8e42d9f3318f5c454cf8b168d814ec97c6f2afc38756d4b1b806184f6d/pydantic_monty-0.0.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:af890d691f6055491a4e643dd5bf09e07bd7a20ad70038531aada6415ab8794a", size = 7344138, upload-time = "2026-04-22T20:13:29.155Z" },
{ url = "https://files.pythonhosted.org/packages/56/c8/cfaf0a56087301d4e88f72cf54ea45a7eebc09c021c85b8864447f1e3755/pydantic_monty-0.0.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f38a69858dfdd2c9474156616d05e25a288e2080aee24152fa40c19ad425f0e", size = 7334903, upload-time = "2026-04-22T20:14:31.489Z" },
{ url = "https://files.pythonhosted.org/packages/51/77/a751a6f73f854aa85fed94cfa5ecab21d7bf218c9fa03c96f9edf470cc4e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bb88264e291cee56770a775f57125538c4713c6d362e89ee63bff506f650a0df", size = 7864258, upload-time = "2026-04-22T20:13:15.594Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/2eb51eb37e9f712cada64fa8d7df4b63b1f5fc635290147ab158ff0e1ef1/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54c317611454aba8be7ca96aeeea9429f4702a5c4ba89812bea82bed0d8e34fd", size = 7138153, upload-time = "2026-04-22T20:14:22.255Z" },
{ url = "https://files.pythonhosted.org/packages/bb/15/835b10cdec3b96b089eef9899df6850b7f84a10225c491698b0ecf8e532a/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9563b5b4933f0f08c0e66ec66aaa4f43f2388bcc04b984e58aab2146dacd3829", size = 7443572, upload-time = "2026-04-22T20:13:17.951Z" },
{ url = "https://files.pythonhosted.org/packages/16/92/aca140923fad8a2821a135cfeaa2fbb3321063bbadaa760424a016bb1ac6/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35f267a501bc1910178a1515fdd3dd927273fbb44e44b8718cb3b33aee79f41b", size = 7967178, upload-time = "2026-04-22T20:14:06.032Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/7c4ff1e3fe2e82a4745decfca67b54a7a61cd306875e32d8e41c5192c69e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35b000c52755f25f322ea7c4d079f09aa60635ffe24a6463899e423066a41bf3", size = 8198241, upload-time = "2026-04-22T20:15:21.2Z" },
{ url = "https://files.pythonhosted.org/packages/30/0b/702db7b753b96ebc6713e7cbdfaecdb471df3e3cb0f0f6e828620a743b78/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61b517776ad13aa4580b1dd89188b18296ceeaf88256423563bbc99e804fd83f", size = 7768859, upload-time = "2026-04-22T20:13:20.044Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/8d16e0cc0c36d1444f25d57da68dd22216bf0961c457a482429cec32141b/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5da5362ef25665a23a3b13024497719f65cafa61d696cac76429f84701bee2e2", size = 7316674, upload-time = "2026-04-22T20:14:52.579Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4d/d47ae703d402e45475333c4bf11b117c8068305f00c1363dbaea13d0fd09/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7e655b6ddd552c02b751f1d57fc291fbd5654ff8b166a8bd634857879160d0b7", size = 7767515, upload-time = "2026-04-22T20:15:16.539Z" },
{ url = "https://files.pythonhosted.org/packages/99/9b/e17fb50d0df5cf9908f8fffa25c5909ed0eb92ca102ded06f7a6d6133e78/pydantic_monty-0.0.17-cp311-cp311-win32.whl", hash = "sha256:ea8b3ae8c42d572cefad841d3bda63cc458d9de2361cb9172914250e6dbe2c75", size = 7230347, upload-time = "2026-04-22T20:14:08.083Z" },
{ url = "https://files.pythonhosted.org/packages/5e/82/d3119f59652d04bcf69d671ddbd38464d5775fbc738a258d3c8f7800e29d/pydantic_monty-0.0.17-cp311-cp311-win_amd64.whl", hash = "sha256:3293c2f7524bfc7c3d8c794f1c1dc1eb4cf9c65a5e222061e2218ced85f3f6df", size = 8074183, upload-time = "2026-04-22T20:14:50.42Z" },
{ url = "https://files.pythonhosted.org/packages/d1/31/95827babdb35149f076c5d191b6b1e7a7c58f4bc72432f905e02e4e3231e/pydantic_monty-0.0.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27c2254fa7a7b05e969f79578889230d293c62e0b1ee28371ec4f3c54b14426a", size = 7342248, upload-time = "2026-04-22T20:15:18.775Z" },
{ url = "https://files.pythonhosted.org/packages/cb/67/ca9cfc07cd445d22def53e9db86912f9ae3e11ef772ce41c2ff41a47eac5/pydantic_monty-0.0.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:445cc471ce6f5a88ef06741b7ebc7002a2253d182f55a2f47094d4adedaaf497", size = 7311255, upload-time = "2026-04-22T20:15:27.913Z" },
{ url = "https://files.pythonhosted.org/packages/df/96/abc9c4972d91a9673435b84e12b99d038e42d1f99648fc9e5f242e09d00e/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:39121038405911f59da7bf61164251f59bad3fb1b0cd28f43c42c3949eee2c8a", size = 7868109, upload-time = "2026-04-22T20:15:04.779Z" },
{ url = "https://files.pythonhosted.org/packages/75/82/9e4d55529bb99d882b9277a721762537a8bc1345ab1d052bb614a88bd15b/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafc8ffe57c257002f623afdb7d0e41f73de850179ebd90b42611e4f2b6f9884", size = 7139709, upload-time = "2026-04-22T20:15:25.386Z" },
{ url = "https://files.pythonhosted.org/packages/96/97/f1af6acefb7bb38d73934d6853998bbd327de7418b811372519080d9fd84/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea00838ef8f37dcd8085defcbdfe89fdd05297a6533f1d3f4cad857d13cedc7b", size = 7450444, upload-time = "2026-04-22T20:13:37.974Z" },
{ url = "https://files.pythonhosted.org/packages/1d/91/af92ef409e1c065345cf1451bbcf19e00f70a250b8372ec65143ca9a9238/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683d18089acf14d0de293245b9e37c7f0ec64e6d266f6773144211931aa3ec97", size = 7967525, upload-time = "2026-04-22T20:13:42.674Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1f/23ecd6e268ef24ce6b0fe4a1e76a314990d2e923ac5791e29d657418243d/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1829993dd50cf497cbed66ea9f6c8ff7d157d22592a05c7399f92fb8a549e3c", size = 8199124, upload-time = "2026-04-22T20:15:00.02Z" },
{ url = "https://files.pythonhosted.org/packages/42/2a/36b694ea0c7e202250a81a57faf00f218738da6c5d070c752f2d81cd34ce/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a7869e3f41a54cc588096c52a8a4de25ecd81e75c867ea4164b14ea1ae1a57f", size = 7739623, upload-time = "2026-04-22T20:14:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/98/e5/090357d7bc0f0751d1afbb71330695fa26554699c88ba56ecaad91657088/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9c17663e2c6f07aec5bc54cd7e39a9e20f250a97a9081e1b2b932eb00d0afc5", size = 7317755, upload-time = "2026-04-22T20:14:48.367Z" },
{ url = "https://files.pythonhosted.org/packages/15/63/67200070cf33325ecfda81d4aee3bf312250ce80bd73058103e04e0f3587/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5d2cf98afe2fb124f6ade91d9663d54277478cad417164f83df1854a41ef450c", size = 7769158, upload-time = "2026-04-22T20:14:39.611Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/9ecfbc2f45406cfb247fafdea4f4a8412db3e559a22c4385eb15266ba2c1/pydantic_monty-0.0.17-cp312-cp312-win32.whl", hash = "sha256:b2185cc4effbbd6793eed4e0f0bcb6a3dbfbb3289ea4d47888708813f0a3dd47", size = 7227917, upload-time = "2026-04-22T20:14:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/d3/80/9be3bef8273817ccc17da25c3ce4ff5d5d45e5629c17eacc90cdef073821/pydantic_monty-0.0.17-cp312-cp312-win_amd64.whl", hash = "sha256:7833daed757ec9b09b627cc3577a4a76b114c5148f779531d7cfdb1095bcf0a9", size = 8043469, upload-time = "2026-04-22T20:13:22.102Z" },
{ url = "https://files.pythonhosted.org/packages/b5/44/0e106b8b27eb93b66e4f3d279486464e05ba5ee31088848e58b5f506f879/pydantic_monty-0.0.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0cdac8c3c16477596bc96ee1cec4f2fbaccd089e2daa1e7b9f227cc89f97cb1", size = 7341507, upload-time = "2026-04-22T20:14:41.717Z" },
{ url = "https://files.pythonhosted.org/packages/e5/88/a0315fa08e62e2d1ef00c03d8202d7bef3f1f71543bebfb916fea265c0a2/pydantic_monty-0.0.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37290d6a1c35aba5cfb8b490bb31c0d822e8ddca8f3ca9ea068e30930d80dd1e", size = 7311916, upload-time = "2026-04-22T20:13:34.022Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e5/e4da6acb408594cbfbfb8dd3c0491b9b2ee54e9183e7ebc5f584baa07af9/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:49f252b2fb918686d3e8f76cb30245e782d1560a7fa68dbc0f6940d83c12bd41", size = 7867465, upload-time = "2026-04-22T20:13:31.466Z" },
{ url = "https://files.pythonhosted.org/packages/58/d4/64c2f8eb708a743b0944ea8f71dfd51bc655285b4be28d55577dafbb29fa/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2254d25c34463d67069f5f1567157bde9311654cd5371f8933b8ea9815bfa26a", size = 7139262, upload-time = "2026-04-22T20:14:10.715Z" },
{ url = "https://files.pythonhosted.org/packages/0c/67/5d766f9cd304e871a5dfe5f0a85eaa533538ef07e9b2858fdf9f37f83694/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0688a1fa5dc045ac7b7e996d7269b94f74f116d7b1e352c7b5bb5ad53d4fe03", size = 7450119, upload-time = "2026-04-22T20:13:44.515Z" },
{ url = "https://files.pythonhosted.org/packages/33/98/fa16779021d93edb19807e87cdba56bbec6adfad21f10b41a212572ce513/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b35121ac555ed201405c69d531e4cb916da6984b8cd2e15c8a319117349faf6", size = 7967398, upload-time = "2026-04-22T20:13:55.576Z" },
{ url = "https://files.pythonhosted.org/packages/92/64/287a42720bc9e975ab5b52625aa9fc6bcef8298dd821022cc45c6ee1808d/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c97dc44af25d4392b474902fc40f78f09fe8f44ba791364670334fe12abc077", size = 8198835, upload-time = "2026-04-22T20:15:02.072Z" },
{ url = "https://files.pythonhosted.org/packages/97/37/03edb1fd582b79b2b462afc3fea5e1c8fea73afefc4870dc35fc3c7c492e/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ed2fb365ef9ca921de9a17786ecfa2efe06e65678e6ca57be51658a2a880f31", size = 7739241, upload-time = "2026-04-22T20:13:46.925Z" },
{ url = "https://files.pythonhosted.org/packages/11/0b/b765c9ca2ae27def1caa07345aba073ae1239fc2d9cca7a375f3dc2195f7/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5bf9f07b38dd12747e3c95b169a5afb3e2e9107622e01e548246c84d19a69c99", size = 7316719, upload-time = "2026-04-22T20:14:13.058Z" },
{ url = "https://files.pythonhosted.org/packages/e4/3b/64fe872cd575ab5262e1ba2959554ead198c939cfaa425f7f8e9b1ad2694/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e9bafd4b5acbc0a8e12ee8403a3ce37281c3b0fa5909d3f412bee76c69003c", size = 7769150, upload-time = "2026-04-22T20:13:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/76/b4/b6a0bb41f39bac2e11e6a2fd42ca0886893fafa6344fb17c3f0a94e22e83/pydantic_monty-0.0.17-cp313-cp313-win32.whl", hash = "sha256:1c239ae3e610d3f39cd1609285209a4e2d046b465ac1bfed0d4374c615eed0fa", size = 7227705, upload-time = "2026-04-22T20:15:12.262Z" },
{ url = "https://files.pythonhosted.org/packages/86/e7/d8cd62f537f7ab17714ee19ea221a0e341dab407215376ab1c41d79794c9/pydantic_monty-0.0.17-cp313-cp313-win_amd64.whl", hash = "sha256:1886c3590b02f359ae991f1e76691064f167330eda4fbf22762127ce17d0eb48", size = 8043469, upload-time = "2026-04-22T20:14:24.86Z" },
{ url = "https://files.pythonhosted.org/packages/69/c0/8354baf835e1a04c4b9e11d253f82df7d625a9305e6a23a177fb895b1484/pydantic_monty-0.0.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a166bd04d1996f0d144fbb5e1391cd1c0fbdacd4fa3b689dd48931388679fe98", size = 7341303, upload-time = "2026-04-22T20:14:35.653Z" },
{ url = "https://files.pythonhosted.org/packages/00/ac/d58221b5e17915421ca00bb08b805ac121b6accb194785d5422da4a2f5fc/pydantic_monty-0.0.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d82f319e3fd79707a7b81bbb68596509d14ac73502d2f0daf4ab5d281efdfbdc", size = 7318912, upload-time = "2026-04-22T20:13:59.966Z" },
{ url = "https://files.pythonhosted.org/packages/81/3f/8eeb8f652f6cd6e06a737aa9f00de2949e37a669b31756bc51b6182457b4/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f38b9875f7ff56fe69538b60b2ecbdcbe2b8b7780407ceb05fe0ee1414bf8d19", size = 7867027, upload-time = "2026-04-22T20:13:51.426Z" },
{ url = "https://files.pythonhosted.org/packages/55/ba/ec6620c27c8b4cada6ce53378c52c245e238e50a20b968cafdc1b8573c4e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74a778bb5a4dcdbc85b3b9002f9a72a43fb6ffd88635bfdd502e8d3053008337", size = 7137542, upload-time = "2026-04-22T20:13:35.939Z" },
{ url = "https://files.pythonhosted.org/packages/41/78/5419785630511b54b15cfb094871bcb53ec9025ba8d91bd7ab5b22b6c98f/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e2ab074a65738e9c1b4be9a432e7ca1e9987a6706018dc7a5af6d4ce7cecdf3", size = 7450222, upload-time = "2026-04-22T20:13:53.378Z" },
{ url = "https://files.pythonhosted.org/packages/61/04/cec11fa96a47034da3c21af53e73f43d2270f5ce96cd710809859fe9c0b2/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00fd1cd28b4200c9ccd02629868486b366af1bfe1f0584d4c9e513b7a941a868", size = 7967405, upload-time = "2026-04-22T20:14:03.826Z" },
{ url = "https://files.pythonhosted.org/packages/81/e3/f2be0fb975100b6936ca36a8410098f10fab3b26729c0b0d1de2fac59ff3/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ea6684555bfd00cbe9d2df3e73caada3d82200c168c63cc475197b55b88401", size = 8199028, upload-time = "2026-04-22T20:13:26.802Z" },
{ url = "https://files.pythonhosted.org/packages/2c/43/358bdaa9c50d21fe4a25a71d43ce9af2d6796616fa47aca84f807433564e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f804a03a3bbd0cf0ade1d4ce11b50ca6858e9c4440b27c746faf1d3c0a272954", size = 7749903, upload-time = "2026-04-22T20:15:09.626Z" },
{ url = "https://files.pythonhosted.org/packages/9a/da/8bcd0a78abf13edceca36aaf5c180fad963e4c2e042fd7247b9e96048306/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:b5476e6c08b86b0bea554b97ce9b142aac1177447f2d3c5751864b27991fd1b1", size = 7312704, upload-time = "2026-04-22T20:14:33.668Z" },
{ url = "https://files.pythonhosted.org/packages/88/49/5de8bb7f8b82c3ebb8f2485e0b7a40055b072193039d95dcb5d35fcba72c/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b5fcdcca45439844bee268686f37226dbf5803ec7a5945f5536c41419f151dac", size = 7768902, upload-time = "2026-04-22T20:14:29.389Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7c/500a002f1a52f17c8b8a989875da3e1590c18ce95c89856aa967e2348a36/pydantic_monty-0.0.17-cp314-cp314-win32.whl", hash = "sha256:4dd3e6e80a415e7272f7a7583a4f8e045096653f6074e181117eb61fc8fe3b45", size = 7227049, upload-time = "2026-04-22T20:14:01.871Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/b8f552f2a863778f49ebb708f18aaf0bfb275480a48965786e06efacf1c4/pydantic_monty-0.0.17-cp314-cp314-win_amd64.whl", hash = "sha256:36a8090a628e8cf91df8f66c721a71050ac8f48473d4992b9afbd9585941a647", size = 8062999, upload-time = "2026-04-22T20:14:44.006Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.1"