Compare commits

...
Author SHA1 Message Date
462f37e77d Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 18:19:34 -07:00
Azure SRE Agent 70c88d2150 docs: clarify checkpoint storage security model and deserialization trust boundaries
Add Security Model documentation sections to the checkpoint encoding and
Azure Functions serialization modules explaining:
- Checkpoint storage is a trusted data source requiring access controls
- The RestrictedUnpickler allowlist is defense-in-depth, not a security boundary
- Developer responsibilities for securing storage backends
- Guidance on using allowed_types and strip_pickle_markers

Co-authored-by: Azure SRE Agent <noreply@microsoft.com>
2026-06-03 01:01:00 +00:00
Peter IbekweandGitHub 6086a74302 Python: Promote agent-framework-declarative package to RC (#6256)
* Promote agent-framework-declarative package to RC

* Update missed package status file.
2026-06-02 19:30:05 +00:00
fa8cfb7567 Python: Fix FoundryAgent stripping model from PromptAgent requests (#5526)
* Fix FoundryAgent stripping model from PromptAgent requests

Move run_options.pop('model', None) inside the _uses_foundry_agent_session()
conditional so that model is only stripped for hosted agent sessions (where
the server manages the model) and preserved for PromptAgent requests that
require it in the Responses API call.

Fixes #5525

* test: add coverage for resp_* continuation preserving model

Adds test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation
to explicitly verify that HostedAgent v1 / v2-no-session paths (where conversation_id
starts with resp_) preserve model and previous_response_id without triggering the
hosted-session gate.

---------

Co-authored-by: Benke Qu <bequ@microsoft.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-06-02 18:30:04 +00:00
6de4c24fdd .NET: Promote Workflows.Declarative packages to stable versions (#6254)
* Promote Workflows.Declarative packages to stable versions

* Address PR feedback: enable package validation on GA declarative packages

Both Workflows.Declarative and Workflows.Declarative.Mcp set IsReleased=true

but were disabling package validation, bypassing the repo's GA convention

(see dotnet/nuget/nuget-package.props which auto-enables validation when

IsReleased=true).

Re-enable validation by removing the local EnablePackageValidation=false

overrides and pointing PackageValidationBaselineVersion at 1.8.0-rc1 (the

latest published version of each package). This catches accidental breaking

changes between RC and the first GA. Future GAs should bump the baseline to

the previous GA version.

Verified locally: dotnet build -c Release on both projects runs

RunPackageValidation -> APICompat ran successfully without finding any

breaking changes.

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

* Update statement for the baseline validation.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-02 15:10:02 +00:00
Dineshsuriya DandGitHub a5f355e04a Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append (#5913)
* Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append

Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP —
the SDK auto-appends /v1/traces, /v1/metrics, /v1/logs when it reads the
env var directly. Signal-specific endpoint env vars are *full* URLs used
verbatim.

_get_exporters_from_env read the base endpoint and forwarded it as the
constructor ``endpoint=`` argument, which the SDK always treats as a full
signal URL. As a result, with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
and HTTP protocol, the exporter sent to http://localhost:4318 instead of
http://localhost:4318/v1/traces (and likewise for metrics/logs).

Replicate the spec's auto-append here when falling back to the base
endpoint under HTTP. gRPC behavior is unchanged.

* Python: Fix mypy type errors in OTLP endpoint assignment

Pre-declare traces_endpoint, metrics_endpoint, logs_endpoint as
str | None before the if/else block. Mypy inferred str from the
if-branch f-string assignments and then rejected the str | None
expressions in the else-branch as incompatible.
2026-06-02 09:59:50 +00:00
0cf48923cd .NET: Add Hosted-ToolboxMcpSkills sample (#6175)
* .NET: Add Hosted-ToolboxMcpSkills sample

Adds a hosted Foundry Responses sample that discovers MCP-based skills from a Foundry Toolbox and makes them available to the agent via AgentSkillsProvider.

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

* Align README and Program.cs default model to gpt-5

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

* Clarify MCP skills provider log to avoid implying eager discovery

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

* Drop redundant skills provider configured log

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

* Add Foundry Toolbox Skills tag to manifest

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

* Simplify BearerTokenHandler by deriving from HttpClientHandler

Removes the need for an explicit InnerHandler. Enables CheckCertificateRevocationList to satisfy CA5399.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-02 08:41:21 +00:00
cdc4809b8a ci: harden Python test coverage workflow (#5982)
Improve input handling and token management in the Python test coverage
workflows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-02 07:43:08 +00:00
Hameed KunkanoorandGitHub 043208241a Python: Persist hosted MCP call/results as canonical mcp_call output (#6070)
* Persist hosted MCP call/results as canonical mcp_call output

- Preserve hosted MCP call/result pairs as canonical mcp_call output items

- Coalesce MCP call + result in non-streaming conversion path

- Keep call-id alignment for MCP tool call tracking and output mapping

- Update tests and package metadata

* Fix missing Mapping import in hosted responses adapter

* Fix pyright unknown type in MCP output stringification

* Fix typing for MCP output sequence iteration

* Improve MCP output robustness and avoid eager flattening

* Bump foundry_hosting to b7 and update responses dependency to b7

* Restore foundry_hosting package version to 1.0.0a260521

* Refactor hosted MCP output parsing
2026-06-02 07:30:36 +00:00
Yufeng HeandGitHub 05ebb966cf fix: skip orphan anthropic thinking signatures (#5784) 2026-06-02 00:48:42 +00:00
Evan MattsonandGitHub c83a944e85 Fix open pr count check (#6255) 2026-06-02 09:09:36 +09:00
Thota Sai KarthikandGitHub 5d98beddf5 Python: feat(bedrock): implement native structured output support via Converse API (#6052)
* feat(bedrock): add structured output support via Converse API (Fixes #5966)

* fix(bedrock): improve unsupported model exception handling and schema parsing

* refactor(bedrock): use generic traversal for strict schema enforcement

* address Copilot review comments on structured output

* refine bedrock structured output: guard additionalProperties, TypeError check, docs + test

* fix(bedrock): widen response_format to Mapping and add missing test coverage
2026-06-01 23:30:19 +00:00
e0d0ad16a0 Python: feat(evals): Foundry Adaptive Evals integration (rubric-generation) (#6101)
* Python: feat(evals): RubricScore type + EvalScoreResult.dimensions

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

* Python: feat(foundry-evals): RubricDimension + GeneratedEvaluatorRef + accept in evaluators=

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

* Python: feat(evals): parse rubric_scores from output items + assertion helpers

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

* Python: feat(evals): BaseAgent.as_eval_source / Workflow.as_eval_source

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

* Python: feat(foundry-evals): EvalGenerationSource + generate_rubric helper

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

* Python: feat(foundry-evals): YAML config loader + sample

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

* Python: fix(evals): address PR review feedback

Addresses 4 Copilot review comments on PR #6101:

1. assert_dimension_score_at_least: drop the (not evaluator or found_any) guard so require_applicable=True correctly raises when the named evaluator produces no entries for the dimension. Adds TestRubricAssertions covering the regression.

2. GeneratedEvaluatorRef docstring: reword to describe actual behaviour (pinning recommended, not required) so it matches the dataclass default and FoundryEvals warning path.

3. _poll_generation_job: switch from asyncio.get_event_loop() to get_running_loop() and bound the per-iteration sleep by remaining time, matching _poll_eval_run.

4. generate_rubric: type category as Literal['quality','safety'] and validate at the entry point with a ValueError; drop the silent 'invalid -> quality' rewrite in _generation_job_to_ref. Adds a regression test.

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

* Python: feat(foundry-evals): hosted-agent-aware rubric generation

* Auto-detect hosted Foundry agents in agent_as_eval_source: when the
  agent's chat_client exposes a string agent_name (the convention used
  by RawFoundryAgentChatClient for PromptAgents/HostedAgents), emit a
  type='agent' EvalGenerationSource so the service fetches instructions
  and tools from the agent registry instead of relying on the local
  wrapper (which holds neither for hosted agents).
* Add hosted_agent_version kwarg and a new agent_version field on
  EvalGenerationSource so PromptAgent runs can pin to a specific hosted
  version for reproducible rubric generation.
* Add force_prompt_source escape hatch to bypass auto-detection and
  always emit a rendered prompt dossier - useful when the local wrapper
  carries overrides the service-side agent doesnt see.
* Fix _to_sdk_source for dataset sources: SDK ctor takes name=/version=,
  not dataset_name=/dataset_version=. The mismatch would raise TypeError
  against the real azure-ai-projects 2.3.0a* SDK; only unmocked
  integration paths were affected.

Tests cover: auto-detection happy path, versionless hosted agent,
explicit hosted_agent_version forwarding, force_prompt_source override,
non-string chat_client attrs (MagicMock test doubles) not mis-detected,
agent_version forwarded through _to_sdk_source, and the corrected
dataset SDK kwarg names.

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

* fix(foundry-evals): accept canonical dimension_scores key per docs

The published Foundry rubric-evaluator output (Microsoft Learn 'Rubric evaluators' reference) places per-dimension breakdowns under properties.dimension_scores, not properties.rubric_scores. The parser now tries dimension_scores first and falls back to rubric_scores for preview-build compatibility, and tolerates non-list payloads (e.g. MagicMock auto-attrs) by trying the next candidate when parsing yields zero entries.

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

* feat(foundry-evals): add manual create_rubric_evaluator

Adds FoundryEvals.create_rubric_evaluator as the agent-framework surface over project_client.beta.evaluators.create_version. This is the manual counterpart to generate_rubric: callers supply RubricDimension instances (authored locally, ported from another framework, or hand-tuned) and we POST a RubricBasedEvaluatorDefinition. The service auto-attaches the non-editable residual dimension (general_quality for quality, general_policy_compliance for safety).

Per the Microsoft Learn 'Rubric evaluators' reference, the auto-generation path (create_generation_job) is primarily a portal/UI feature; external SDK clients with rich local agent context are better served by manual create_version. This keeps generate_rubric for users who want to round-trip through a Foundry-registered agent.

Validation up front: weight must be in [1,10], ids unique, descriptions non-empty, pass_threshold in [0,1]. The returned GeneratedEvaluatorRef is identical in shape to one obtained from generate_rubric, so downstream evaluators= lists work unchanged.

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

* samples(foundry-evals): manual rubric sample + namespace re-exports

Adds evaluate_with_manual_rubric_sample.py demonstrating the end-to-end dev scenario for FoundryEvals.create_rubric_evaluator: hand-author a list of RubricDimension, register via create_rubric_evaluator, then use the pinned GeneratedEvaluatorRef alongside built-in evaluators in an agent regression run.

Also re-exports RubricDimension, GeneratedEvaluatorRef, build_sources, and load_evals_config from agent_framework.foundry (both the lazy runtime shim and the type stub) so the rubric samples can import everything from a single namespace; the auto-generate sample was previously broken because the shim was missing build_sources / load_evals_config.

Updates the foundry-evals README with a chooser entry for the two rubric paths.

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

* feat(foundry-evals): remove rubric creation flows; keep consumption only

Reframes agent-framework as a pure consumer of Foundry rubric evaluators: scoring against rubrics that already exist (authored in the Foundry portal or via the dedicated SDK / REST surface) instead of creating them from the SDK.

Removed creation surface area:

- FoundryEvals.generate_rubric (auto-generate path) and create_rubric_evaluator (manual path), plus all _GenerationSdkTypes / _ManualRubricSdkTypes / _to_sdk_dimensions / _coalesce_generation_sources / _to_sdk_source / _poll_generation_job / _generation_job_to_ref / _evaluator_version_to_ref / _get_beta_evaluators / _import_*_sdk_types helpers.

- EvalGenerationSource (the input source discriminator), RubricDimension (the input dimension type), agent_as_eval_source / workflow_as_eval_source / _detect_hosted_foundry_agent helpers, and the YAML-config loader (_evals_config.py with RubricGenerationSpec / RubricSourceSpec / parse_evals_config / load_evals_config / build_sources).

- BaseAgent.as_eval_source / Workflow.as_eval_source plus the _render_agent_dossier / _render_workflow_dossier helpers in core. These existed only to feed the now-removed generation pipeline.

- Samples evaluate_with_generated_rubric_sample.py, evaluate_with_manual_rubric_sample.py, and evaluators.yaml. Replaced with a short README section showing how to reference an existing rubric evaluator via GeneratedEvaluatorRef.

Kept (consumption surface):

- GeneratedEvaluatorRef, slimmed to (name, version, display_name). Still accepted alongside built-in evaluator strings in FoundryEvals(evaluators=[...]). Versionless refs still warn.

- RubricScore on EvalScoreResult.dimensions plus EvalResults.assert_dimension_score_at_least for per-dimension CI gates.

- _parse_dimension_entries / _extract_rubric_scores output parsing (both canonical dimension_scores and the legacy rubric_scores key).

Tests: 160/160 foundry unit tests and 71/71 core local-eval tests pass; pyright is clean across changed files. The pre-existing tests/core/test_telemetry.py::test_detect_hosted_fallback_import_error failure is unrelated and reproduces on the prior commit.

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

* samples(foundry-evals): add evaluate_with_rubric_sample

Adds a runnable end-to-end sample showing how to consume a pre-existing rubric evaluator created in Foundry: reference it with GeneratedEvaluatorRef(name, version), mix it with built-in evaluators in FoundryEvals, and gate CI with assert_dimension_score_at_least on a specific dimension.

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

* fix(foundry-evals): satisfy mypy on _fetch_output_items

mypy infers OutputItemListResponse.sample as dict[str, object] | None while pyright correctly infers the typed Sample model. Cast to Any so both type checkers accept the attribute access pattern, rename the local to avoid shadowing the inner-loop sample binding, and drop the now-stale pyright suppressions.

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

* docs(foundry-evals): drop unpublished rubric-evaluators learn.microsoft.com link

The Adaptive Evals authoring docs are not yet published on Microsoft Learn, so the link 404s. Keep the descriptive text without the broken hyperlink; we can re-add it once the docs ship.

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

* test(foundry-evals): hoist repeated local imports to module top

Per code review feedback (eavanvalkenburg): the test file repeated 'from agent_framework_foundry._foundry_evals import ...' inside 22 test bodies and 'from agent_framework_foundry import GeneratedEvaluatorRef' inside 8 more. Move all of them to the existing top-level imports; the symbols are the same across tests and the local imports were redundant.

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

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 23:01:56 +00:00
f36096ce1a Python: Fix core observability unsafe serialization of function-call arguments containing dataclass/framework objects (#6026)
* fix: safely serialize function-call arguments in core observability

Apply make_json_safe() to content.arguments in _to_otel_part() before
building the otel message dict, so that dataclass/framework payloads
(e.g. workflow request_info events) do not cause a TypeError when
_capture_messages() calls json.dumps().

Lift make_json_safe() into agent_framework._serialization (no new
external deps — dataclasses/datetime only) so the core observability
path can use it without a dependency on the ag-ui adapter.

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

* fix(core): safely serialize workflow request_info payloads in observability (#5733)

- Add make_json_safe() helper to recursively convert non-serializable objects
- Use make_json_safe() in _to_otel_part() for function_call arguments
- Fix CustomPayload test class to use @dataclass (resolves B903 lint error)

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

* fix(serialization): guard callability and normalize dict keys in make_json_safe (#5733)

- Use callable(getattr(obj, method, None)) instead of hasattr() so that
  non-callable attributes named model_dump/to_dict/dict do not raise
  TypeError at runtime.
- Wrap each call in try/except TypeError to handle callables with
  mandatory arguments gracefully.
- Convert dict keys to str() so that non-string keys (e.g. datetime,
  int) cannot cause json.dumps to raise TypeError.
- Add regression tests for both scenarios.

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

* Address observability serialization review feedback

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 21:41:52 +00:00
03e14ca187 .NET: Update hosted agents (#6243)
* Updating to latest Foundry hosting packages.

* Re-applying .gitignore.

* Adding empty line at end of .gitignore

---------

Co-authored-by: Ben Thomas <25218250+alliscode@users.noreply.github.com>
2026-06-01 21:27:29 +00:00
b298113d15 .NET - Fix missing id on function_call_output in Foundry Hosting (#6246)
* Fix missing id on function_call_output in Foundry Hosting

The Foundry storage layer was rejecting responses with
"ID cannot be null or empty (Parameter 'id')" because
function_call_output items emitted by OutputConverter had no id on
the wire.

OutputItemFunctionToolCallOutput's public ctor only sets CallId and
Output; Id is read-only and only the SDK's internal ctor populates
it. OutputItemBuilder<T>.ApplyAutoStamps fills ResponseId and
AgentReference but not Id, so the itemId passed to
AddOutputItem<T>(itemId) was used only for event sequencing and the
serialized item went out with id=null.

Switch to stream.OutputItemFunctionCallOutput(callId, output), the
SDK convenience method that uses the internal ctor and stamps the
id. Add a regression test asserting the added/done events carry a
non-empty matching Id.

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

* ci: free disk space and relocate NuGet cache on ubuntu runners

The ubuntu-latest dotnet-build/test jobs were hitting No space left on device because the runner image only ships ~14 GB free on /. The full multi-TFM build plus the dotnet pack + console-app install-check exhausts that easily.

Add a reusable composite action .github/actions/free-runner-disk-space that runs on Linux runners only and:

* removes pre-installed toolchains we never use here (Android SDK, GHC/Haskell, CodeQL, PyPy, Ruby, Go, boost, vcpkg, etc.), prunes docker images, and disables swap (reclaims ~25-30 GB on /)

* relocates the NuGet package cache to /mnt/nuget via NUGET_PACKAGES env, since /mnt has ~75 GB free on hosted runners

Wire the action into the four ubuntu-touching jobs in dotnet-build-and-test.yml (dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions). The action self-guards with runner.os == 'Linux' so the matrix legs that run on windows are unaffected.

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

---------

Co-authored-by: alliscode <25218250+alliscode@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 18:43:45 +00:00
8091d052d8 Python: refresh dev dependencies and validate runtime bounds (#6238)
Updates third-party dev dependencies across the Python workspace and
validates that all runtime dependency bounds still hold at both ends.

Dev dependency bumps (root, lab, declarative, durabletask):
- uv 0.11.6 -> 0.11.17, ruff 0.15.8 -> 0.15.15,
  pytest-asyncio 1.3.0 -> 1.4.0, mcp 1.27.0 -> 1.27.2,
  azure-monitor-opentelemetry 1.8.7 -> 1.8.8,
  poethepoet 0.42.1 -> 0.46.0, prek 0.3.9 -> 0.4.3,
  types-python-dateutil and types-PyYaml stub bumps.
- Transitive Dependabot items swept via lock: idna 3.11 -> 3.17,
  pip 26.0.1 -> 26.1.2.

Deliberately excluded:
- opentelemetry-sdk stays 1.40.0: azure-monitor-opentelemetry (incl.
  1.8.8) hard-pins opentelemetry-sdk==1.40.
- mypy stays 1.20.0 and pyright stays 1.1.408: the 2.1.0 / 1.1.409
  bumps introduce new diagnostics that fail type checking and need
  dedicated PRs.
- rich kept as a range: agentlightning (lab[lightning]) forces
  rich==13.9.4.

Code/formatting changes driven by the ruff upgrade:
- devui lifespan now uses try/finally so shutdown cleanup always runs
  (ruff RUF075).
- Removed unused TYPE_CHECKING imports in core and foundry flagged by
  ruff 0.15.15.
- Reapplied ruff 0.15.15 formatting to the files it changed.

Validation: validate-dependency-bounds-test "*" passes (31/31 lower +
31/31 upper); typing 62/62; lint 31/31; devui tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 17:53:56 +00:00
westeyandGitHub 52a8045bb6 Python: Add background agent support to harness agent (#6155)
* Add background agent support to harness agent

* Address PR comments
2026-06-01 17:20:39 +00:00
94 changed files with 3346 additions and 603 deletions
@@ -0,0 +1,64 @@
name: Free runner disk space
description: |
Reclaims disk space on GitHub-hosted Ubuntu runners by removing
pre-installed toolchains we do not use (Android SDK, GHC/Haskell,
CodeQL bundle), Docker images, and swap. Also relocates the
NuGet package cache to /mnt (which has ~75 GB free vs ~14 GB
on /). No-op on non-Linux runners.
runs:
using: composite
steps:
- name: Free disk space (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
echo "::group::Disk usage before cleanup"
df -h /
echo "::endgroup::"
# Remove pre-installed toolchains we never use on this repo's
# dotnet/python jobs. These reclaim ~25-30 GB on ubuntu-latest.
sudo rm -rf \
/usr/local/lib/android \
/usr/share/dotnet/sdk/NuGetFallbackFolder \
/opt/ghc \
/usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby \
/opt/hostedtoolcache/go \
/usr/local/share/boost \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/vcpkg \
/usr/local/lib/heroku \
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/PyPy" \
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/Ruby" \
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/go" || true
# Drop docker images shipped on the runner; jobs that need
# docker pull what they need fresh.
if command -v docker >/dev/null 2>&1; then
sudo docker image prune --all --force >/dev/null 2>&1 || true
fi
# Disable swap to free its backing file.
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile || true
echo "::group::Disk usage after cleanup"
df -h /
echo "::endgroup::"
- name: Relocate NuGet package cache to /mnt (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
sudo mkdir -p /mnt/nuget
sudo chown -R "$USER":"$USER" /mnt/nuget
echo "NUGET_PACKAGES=/mnt/nuget" >> "$GITHUB_ENV"
echo "Relocated NuGet package cache to /mnt/nuget"
df -h /mnt || true
+12 -9
View File
@@ -63,19 +63,22 @@ function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount })
}
async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) {
const query = `repo:${owner}/${repo} is:pr is:open author:${author}`;
const response = await github.rest.search.issuesAndPullRequests({
q: query,
const openPullRequests = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
const indexedPrNumbers = response.data.items.map((item) => item.number);
const currentPrIsIndexed = indexedPrNumbers.includes(pullRequestNumber);
if (currentPrIsIndexed || response.data.total_count >= 100) {
return response.data.total_count;
}
const authorOpenPullRequestNumbers = openPullRequests
.filter((pullRequest) => pullRequest.user?.login === author)
.map((pullRequest) => pullRequest.number);
const currentPrIsOpen = authorOpenPullRequestNumbers.includes(pullRequestNumber);
const existingOpenPrCount = currentPrIsOpen
? authorOpenPullRequestNumbers.length - 1
: authorOpenPullRequestNumbers.length;
return response.data.total_count + 1;
return existingOpenPrCount + 1;
}
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
+57 -27
View File
@@ -44,23 +44,20 @@ function createCore() {
};
}
function createGithub({ totalCount, itemNumbers, labelExists = true }) {
function createGithub({
itemNumbers,
labelExists = true,
pullRequests = createPullRequestPage({ numbers: itemNumbers }),
}) {
const calls = [];
return {
calls,
async paginate(method, params) {
calls.push({ api: 'paginate', method, params });
return pullRequests;
},
rest: {
search: {
async issuesAndPullRequests(params) {
calls.push({ api: 'search.issuesAndPullRequests', params });
return {
data: {
total_count: totalCount,
items: itemNumbers.map((number) => ({ number })),
},
};
},
},
issues: {
async getLabel(params) {
calls.push({ api: 'issues.getLabel', params });
@@ -85,6 +82,10 @@ function createGithub({ totalCount, itemNumbers, labelExists = true }) {
},
},
pulls: {
async list(params) {
calls.push({ api: 'pulls.list', params });
return { data: pullRequests };
},
async update(params) {
calls.push({ api: 'pulls.update', params });
return { data: { state: params.state } };
@@ -94,6 +95,15 @@ function createGithub({ totalCount, itemNumbers, labelExists = true }) {
};
}
function createPullRequestPage({ author = 'community-user', numbers }) {
return numbers.map((number) => ({
number,
user: {
login: author,
},
}));
}
// ---------------------------------------------------------------------------
// PR limit enforcement
@@ -102,7 +112,6 @@ function createGithub({ totalCount, itemNumbers, labelExists = true }) {
describe('PR limit enforcement', () => {
it('does not close the PR when the author is at the open PR limit', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123],
});
@@ -119,14 +128,13 @@ describe('PR limit enforcement', () => {
assert.equal(result.openPrCount, 10);
assert.deepEqual(
github.calls.map((call) => call.api),
['search.issuesAndPullRequests'],
['paginate'],
);
});
it('counts the new PR when search has not indexed it yet', async () => {
it('counts the new PR when the pull list includes it', async () => {
const github = createGithub({
totalCount: 10,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
itemNumbers: [123, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
});
const result = await enforcePrLimit({
@@ -143,7 +151,7 @@ describe('PR limit enforcement', () => {
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'paginate',
'issues.getLabel',
'issues.addLabels',
'issues.createComment',
@@ -152,9 +160,31 @@ describe('PR limit enforcement', () => {
);
});
it('counts the current PR on top of existing open PRs', async () => {
const github = createGithub({
itemNumbers: [123, ...Array.from({ length: 24 }, (_, index) => index + 1)],
pullRequests: createPullRequestPage({
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
}),
});
const result = await enforcePrLimit({
github,
context: createContext(),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, true);
assert.equal(result.openPrCount, 26);
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
assert.match(comment, /This PR would put you at 26 open pull requests/);
});
it('creates the label when it does not already exist', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
@@ -172,7 +202,7 @@ describe('PR limit enforcement', () => {
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'paginate',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
@@ -188,7 +218,6 @@ describe('PR limit enforcement', () => {
it('tolerates a 422 race when creating the label', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
labelExists: false,
});
@@ -212,7 +241,7 @@ describe('PR limit enforcement', () => {
assert.deepEqual(
github.calls.map((call) => call.api),
[
'search.issuesAndPullRequests',
'paginate',
'issues.getLabel',
'issues.createLabel',
'issues.addLabels',
@@ -224,8 +253,11 @@ describe('PR limit enforcement', () => {
it('uses a diplomatic close message with the configured limit', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
pullRequests: createPullRequestPage({
author: 'octo-contributor',
numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
}),
});
await enforcePrLimit({
@@ -246,7 +278,6 @@ describe('PR limit enforcement', () => {
it('does not close an exempt PR when it is reopened', async () => {
const github = createGithub({
totalCount: 11,
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
});
@@ -265,10 +296,9 @@ describe('PR limit enforcement', () => {
assert.deepEqual(github.calls, []);
});
it('does not over-count when the current PR is not on the first search page', async () => {
it('counts the current PR when the author has more than one page of open PRs', async () => {
const github = createGithub({
totalCount: 101,
itemNumbers: Array.from({ length: 100 }, (_, index) => index + 1),
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
});
const result = await enforcePrLimit({
@@ -121,6 +121,9 @@ jobs:
python
declarative-agents
- name: Free runner disk space
uses: ./.github/actions/free-runner-disk-space
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
@@ -191,6 +194,9 @@ jobs:
python
declarative-agents
- name: Free runner disk space
uses: ./.github/actions/free-runner-disk-space
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
@@ -365,6 +371,9 @@ jobs:
dotnet
python
- name: Free runner disk space
uses: ./.github/actions/free-runner-disk-space
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
@@ -452,6 +461,9 @@ jobs:
python
declarative-agents
- name: Free runner disk space
uses: ./.github/actions/free-runner-disk-space
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
@@ -8,6 +8,7 @@ on:
permissions:
contents: read
actions: read
pull-requests: write
jobs:
@@ -23,7 +24,7 @@ jobs:
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ github.token }}
run-id: ${{ github.event.workflow_run.id }}
path: ./python
merge-multiple: true
@@ -38,9 +39,9 @@ jobs:
echo "PR number file 'pr_number' is missing or empty"
exit 1
fi
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
if [ -z "$PR_NUMBER" ]; then
echo "PR number file 'pr_number' does not contain a valid PR number"
PR_NUMBER=$(cat pr_number)
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::PR number file contains invalid content"
exit 1
fi
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
@@ -48,7 +49,7 @@ jobs:
id: coverageComment
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ github.token }}
issue-number: ${{ env.PR_NUMBER }}
pytest-xml-coverage-path: python/python-coverage.xml
title: "Python Test Coverage Report"
+1
View File
@@ -248,3 +248,4 @@ dotnet/filtered-*.slnx
.omx/
**/issues/
.test_*
+17 -17
View File
@@ -1,17 +1,17 @@
# Support
## How to file issues and get help
This project uses GitHub Issues to track bugs and feature requests. Please search the existing
issues before filing new issues to avoid duplicates. For new issues, file your bug or
feature request as a new Issue.
For help and questions about using this project, please create a GitHub issue.
AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
## Microsoft Support Policy
Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
# Support
## How to file issues and get help
This project uses GitHub Issues to track bugs and feature requests. Please search the existing
issues before filing new issues to avoid duplicates. For new issues, file your bug or
feature request as a new Issue.
For help and questions about using this project, please create a GitHub issue.
AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
## Microsoft Support Policy
Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
+3
View File
@@ -344,6 +344,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,12 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>",
"REDIS_CONNECTION_STRING": "localhost:6379",
"REDIS_STREAM_TTL_MINUTES": "10"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,8 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -0,0 +1,6 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -0,0 +1,26 @@
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
#
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
# which only succeeds when the project references its dependencies via PackageReference (see the
# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the
# agent-framework repository source must use Dockerfile.contributor instead because
# ProjectReference dependencies live outside this folder and cannot be restored from inside
# this build context.
#
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,18 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local source, which means a standard
# multi-stage Docker build cannot resolve dependencies outside this folder.
# Pre-publish the app targeting the container runtime and copy the output:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedToolboxMcpSkills</RootNamespace>
<AssemblyName>HostedToolboxMcpSkills</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Toolbox MCP Skills Agent
//
// Demonstrates how to host an agent that discovers MCP-based skills from a
// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using
// AgentSkillsProviderBuilder.UseMcpSkills().
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5)
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using ModelContextProtocol.Client;
// Load .env file if present (for local development)
Env.TraversePath().Load();
var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5";
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── Connect to the Foundry Toolbox MCP endpoint ─────────────────────────────
// Create an HttpClient that attaches a fresh Foundry bearer token to every request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true });
Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server...");
await using var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = toolboxName,
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// ── Configure MCP-based skills provider ──────────────────────────────────────
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// ── Create the agent ─────────────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills",
Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox",
ChatOptions = new()
{
ModelId = deployment,
Instructions = "You are a helpful assistant.",
},
AIContextProviders = [skillsProvider],
});
// ── Build the host ───────────────────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
// ---------------------------------------------------------------------------
// HttpClientHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,103 @@
# Hosted-ToolboxMcpSkills
A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`.
The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider:
1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available.
2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider.
3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider.
This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
- A Foundry Toolbox already configured with skills provisioned
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your values:
```bash
cp .env.example .env
```
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=my-toolbox
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills
dotnet run
```
The agent will start on `http://localhost:8088`.
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
```
### 3. Run the container
Generate a bearer token on your host and pass it to the container:
```bash
# Generate token (expires in ~1 hour)
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
# Run with token
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-toolbox-mcp-skills \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-toolbox-mcp-skills
```
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,43 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-toolbox-mcp-skills
displayName: "Hosted Toolbox MCP Skills Agent"
description: >
A hosted agent that discovers MCP-based skills from a Foundry Toolbox
and makes them available to the agent via the agent skills provider.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- MCP
- Model Context Protocol
- Agent Skills
- Foundry Toolbox
- Foundry Toolbox Skills
template:
name: hosted-toolbox-mcp-skills
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: FOUNDRY_TOOLBOX_NAME
value: "{{FOUNDRY_TOOLBOX_NAME}}"
parameters:
properties:
- name: FOUNDRY_TOOLBOX_NAME
secret: false
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
resources:
- kind: model
id: gpt-5
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-toolbox-mcp-skills
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: FOUNDRY_TOOLBOX_NAME
value: ${FOUNDRY_TOOLBOX_NAME}
@@ -281,14 +281,19 @@ internal static class OutputConverter
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
// Use the SDK's convenience method so the OutputItemFunctionToolCallOutput
// is constructed with a populated Id. The public OutputItemFunctionToolCallOutput
// ctor only sets CallId/Output (Id is read-only), and AddOutputItem<T>+EmitAdded
// does not auto-stamp Id — only ResponseId/AgentReference. Without this, the
// serialized item arrives at the Foundry storage layer with id=null and is
// rejected with "ID cannot be null or empty (Parameter 'id')".
foreach (var evt in stream.OutputItemFunctionCallOutput(
functionResult.CallId,
BinaryData.FromString(outputText));
BinaryData.FromString(outputText)))
{
yield return evt;
}
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
yield return outputBuilder.EmitAdded(outputItem);
yield return outputBuilder.EmitDone(outputItem);
break;
}
@@ -24,11 +24,13 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<!-- Preview while Microsoft.Agents.AI.Foundry is preview (blocked by Azure.AI.Projects 2.1.0-beta). Flip to IsReleased=true once that ships stable. -->
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<IsReleased>true</IsReleased>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
@@ -13,9 +13,11 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- Package not yet published to NuGet — disable baseline validation until first release -->
<!-- First Stable release after the RC milestone. Baseline against the latest
published RC so package validation catches accidental breaking changes.
Future releases should bump this to the previous stable version. -->
<PropertyGroup>
<EnablePackageValidation>false</EnablePackageValidation>
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
</PropertyGroup>
<PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<IsReleased>true</IsReleased>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
@@ -13,6 +13,13 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- First Stable release after the RC milestone. Baseline against the latest
published RC so package validation catches accidental breaking changes.
Future releases should bump this to the previous stable version. -->
<PropertyGroup>
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
</PropertyGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Declarative Workflows</Title>
@@ -6,11 +6,11 @@
.DESCRIPTION
The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only
manages versions on each test run. The agent itself must already exist AND its managed
identity must hold the Azure AI User role on the project scope, otherwise inbound
identity must hold the Foundry User role on the project scope, otherwise inbound
inference calls fail with HTTP 500 PermissionDenied.
This script idempotently creates each scenario agent (with a placeholder version) and
grants Azure AI User on the project to its managed identity. Re-run it safely; existing
grants Foundry User on the project to its managed identity. Re-run it safely; existing
agents and role assignments are left in place.
.PARAMETER ProjectEndpoint
@@ -135,20 +135,20 @@ foreach ($scenario in $Scenarios) {
-Body $patchBody | Out-Null
}
# 3. Grant Azure AI User on the project scope to the agent MI (idempotent).
# 3. Grant Foundry User on the project scope to the agent MI (idempotent).
$existing = az role assignment list --assignee $principalId --scope $projectScope `
--query "[?roleDefinitionName=='Azure AI User']" 2>$null | ConvertFrom-Json
--query "[?roleDefinitionName=='Foundry User']" 2>$null | ConvertFrom-Json
if ($existing) {
Write-Host " role already assigned"
} else {
Write-Host " granting Azure AI User..."
Write-Host " granting Foundry User..."
$maxAttempts = 12
$granted = $false
for ($i = 1; $i -le $maxAttempts; $i++) {
$output = az role assignment create `
--assignee-object-id $principalId `
--assignee-principal-type ServicePrincipal `
--role 'Azure AI User' `
--role 'Foundry User' `
--scope $projectScope 2>&1
if ($LASTEXITCODE -eq 0) {
$granted = $true
@@ -1,12 +0,0 @@
{
"profiles": {
"Microsoft.Agents.AI.DevUI.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:63009;http://localhost:63010"
}
}
}
@@ -704,6 +704,35 @@ public class OutputConverterTests
Assert.Equal("[{\"id\":1}]", inner);
}
// K-06e: Regression — the OutputItemFunctionToolCallOutput must have a populated Id
// and a matching wire id on the added/done events. The Foundry storage layer extracts
// a partition id from this field and throws "ID cannot be null or empty (Parameter 'id')"
// when it is missing.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResult_OutputItemHasIdAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var done = Assert.Single(events.OfType<ResponseOutputItemDoneEvent>());
var addedOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
var doneOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(done.Item);
Assert.False(string.IsNullOrEmpty(addedOutput.Id));
Assert.False(string.IsNullOrEmpty(doneOutput.Id));
Assert.Equal(addedOutput.Id, doneOutput.Id);
Assert.Equal("call_1", addedOutput.CallId);
Assert.Equal("call_1", doneOutput.CallId);
}
// L-01
[Fact]
public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync()
@@ -1,12 +0,0 @@
{
"profiles": {
"Microsoft.Agents.AI.Hosting.A2A.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:52186;http://localhost:52187"
}
}
}
@@ -1,12 +0,0 @@
{
"profiles": {
"Microsoft.Agents.AI.Hosting.OpenAI.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60491;http://localhost:60492"
}
}
}
+8 -1
View File
@@ -27,7 +27,7 @@ Status is grouped into these buckets:
| `agent-framework-claude` | `python/packages/claude` | `beta` |
| `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` |
| `agent-framework-core` | `python/packages/core` | `released` |
| `agent-framework-declarative` | `python/packages/declarative` | `beta` |
| `agent-framework-declarative` | `python/packages/declarative` | `rc` |
| `agent-framework-devui` | `python/packages/devui` | `beta` |
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
@@ -58,6 +58,13 @@ listed below.
### Experimental features
#### `DECLARATIVE_AGENTS`
- `agent-framework-declarative`: declarative agent loading APIs from
`agent_framework_declarative`, including `AgentFactory`,
`DeclarativeLoaderError`, `ProviderLookupError`, and `ProviderTypeMapping`
from `agent_framework_declarative/_loader.py`
#### `EVALS`
- `agent-framework-core`: exported evaluation APIs from `agent_framework`, including
@@ -287,9 +287,7 @@ class A2AExecutor(AgentExecutor):
artifact_id=artifact_id,
metadata=metadata,
append=(
True
if streamed_artifact_ids is not None and artifact_id in streamed_artifact_ids
else None
True if streamed_artifact_ids is not None and artifact_id in streamed_artifact_ids else None
),
)
if artifact_id and streamed_artifact_ids is not None:
@@ -803,6 +803,15 @@ class RawAnthropicClient(
}
a_content.append(mcp_result)
case "text_reasoning":
if content.text is None:
if (
content.protected_data
and a_content
and a_content[-1].get("type") == "thinking"
and "signature" not in a_content[-1]
):
a_content[-1]["signature"] = content.protected_data
continue
thinking_block: dict[str, Any] = {"type": "thinking", "thinking": content.text}
if content.protected_data:
thinking_block["signature"] = content.protected_data
@@ -485,6 +485,48 @@ def test_prepare_message_for_anthropic_text_reasoning_with_signature(
assert result["content"][0]["signature"] == "sig_abc123"
def test_prepare_message_for_anthropic_attaches_signature_only_reasoning(
mock_anthropic_client: MagicMock,
) -> None:
client = create_test_anthropic_client(mock_anthropic_client)
message = Message(
role="assistant",
contents=[
Content.from_text_reasoning(text="Let me think about this..."),
Content.from_text_reasoning(text=None, protected_data="sig_abc123"),
],
)
result = client._prepare_message_for_anthropic(message)
assert result["content"] == [
{"type": "thinking", "thinking": "Let me think about this...", "signature": "sig_abc123"}
]
def test_prepare_message_for_anthropic_skips_orphan_signature_only_reasoning(
mock_anthropic_client: MagicMock,
) -> None:
client = create_test_anthropic_client(mock_anthropic_client)
message = Message(
role="assistant",
contents=[
Content.from_text_reasoning(text=None, protected_data="sig_abc123"),
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments={"location": "San Francisco"},
),
],
)
result = client._prepare_message_for_anthropic(message)
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "tool_use"
assert result["content"][0]["id"] == "call_123"
def test_prepare_message_for_anthropic_mcp_server_tool_call(
mock_anthropic_client: MagicMock,
) -> None:
@@ -14,6 +14,24 @@ This module adds:
- reconstruct_to_type: for HITL responses where external data (without type markers)
needs to be reconstructed to a known type
- resolve_type: resolves 'module:class' type keys to Python types
Security Model
--------------
The underlying Azure Durable Functions storage (Azure Storage account) is the
trusted persistence layer for serialized checkpoint data. The
``RestrictedUnpickler`` in the core encoding module provides defense-in-depth
type filtering, but checkpoint storage itself must be properly access-controlled:
- Ensure the Azure Storage account used by Durable Functions is not publicly
writable and uses appropriate RBAC / shared-access policies.
- Never route untrusted user input directly into ``deserialize_value`` without
first calling :func:`strip_pickle_markers` to neutralize injection of
pickle markers into the data path.
- Configure your checkpoint storage with ``allowed_checkpoint_types`` (or call
``decode_checkpoint_value(..., allowed_types=...)`` directly) to restrict the set of types that can be deserialized.
See :mod:`agent_framework._workflows._checkpoint_encoding` for the full
security model documentation.
"""
from __future__ import annotations
@@ -4,6 +4,7 @@
from __future__ import annotations
import asyncio
import copy
import json
import logging
import sys
@@ -36,6 +37,7 @@ from agent_framework.observability import ChatTelemetryLayer
from boto3.session import Session as Boto3Session
from botocore.client import BaseClient
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from pydantic import BaseModel
if sys.version_info >= (3, 13):
@@ -115,13 +117,20 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t
translates to ``toolConfig.tools``.
tool_choice: How the model should use tools,
translates to ``toolConfig.toolChoice``.
response_format: Structured output format. Accepts a Pydantic BaseModel
subclass or an OpenAI-style dict schema
(``{"json_schema": {"name": ..., "schema": ...}}``).
When provided, the Converse API request includes
``outputConfig.textFormat`` with the schema serialized as a JSON
string. ``ChatResponse.value`` will be populated with the parsed
model instance. Only supported on models that support
``outputConfig.textFormat``. Unsupported models raise a ValueError.
# Options not supported in Bedrock Converse API:
seed: Not supported.
frequency_penalty: Not supported.
presence_penalty: Not supported.
allow_multiple_tool_calls: Not supported (models handle parallel calls automatically).
response_format: Not directly supported (use model-specific prompting).
user: Not supported.
store: Not supported.
logit_bias: Not supported.
@@ -161,9 +170,6 @@ class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], t
allow_multiple_tool_calls: None # type: ignore[misc]
"""Not supported. Bedrock models handle parallel tool calls automatically."""
response_format: None # type: ignore[misc]
"""Not directly supported. Use model-specific prompting for JSON output."""
user: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
@@ -324,10 +330,28 @@ class BedrockChatClient(
return Boto3Session(**session_kwargs)
def _invoke_converse(self, request: Mapping[str, Any]) -> dict[str, Any]:
response = self._bedrock_client.converse(**request)
if not isinstance(response, Mapping):
raise ChatClientInvalidResponseException("Bedrock converse response must be a mapping.")
return response
try:
response = self._bedrock_client.converse(**request)
if not isinstance(response, Mapping):
raise ChatClientInvalidResponseException("Bedrock converse response must be a mapping.")
return response
except ClientError as e:
error_details = e.response.get("Error", {})
error_code = error_details.get("Code", "")
error_message = error_details.get("Message", "")
# "outputConfig" in error_message catches cases where Bedrock explicitly
# rejects the outputConfig field (unsupported model). Other ValidationExceptions
# (e.g. malformed schema shape, invalid property values) will not mention
# "outputConfig" and will bubble up as raw ClientError without being misdiagnosed.
if error_code == "ValidationException" and (
"outputconfig" in error_message.lower() or "outputconfig" in str(e).lower()
):
raise ValueError(
f"Model '{self.model}' does not support structured output via outputConfig.textFormat. "
"Check the model's Bedrock Converse outputConfig/textFormat support. "
f"AWS error Code: {error_code}. AWS error Message: {error_message}"
) from e
raise
@override
def _inner_get_response(
@@ -344,7 +368,7 @@ class BedrockChatClient(
# Streaming mode - simulate streaming by yielding a single update
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
response = await asyncio.to_thread(self._invoke_converse, request)
parsed_response = self._process_converse_response(response)
parsed_response = self._process_converse_response(response, options)
contents = list(parsed_response.messages[0].contents if parsed_response.messages else [])
if parsed_response.usage_details:
contents.append(Content.from_usage(usage_details=parsed_response.usage_details)) # type: ignore[arg-type]
@@ -360,12 +384,12 @@ class BedrockChatClient(
raw_representation=parsed_response.raw_representation,
)
return self._build_response_stream(_stream())
return self._build_response_stream(_stream(), response_format=options.get("response_format"))
# Non-streaming mode
async def _get_response() -> ChatResponse:
raw_response = await asyncio.to_thread(self._invoke_converse, request)
return self._process_converse_response(raw_response)
return self._process_converse_response(raw_response, options)
return _get_response()
@@ -430,6 +454,9 @@ class BedrockChatClient(
if tool_config:
run_options["toolConfig"] = tool_config
if output_config := self._prepare_output_config(options.get("response_format")):
run_options["outputConfig"] = output_config
return run_options
def _prepare_bedrock_messages(
@@ -628,7 +655,9 @@ class BedrockChatClient(
def _generate_tool_call_id() -> str:
return f"tool-call-{uuid4().hex}"
def _process_converse_response(self, response: dict[str, Any]) -> ChatResponse:
def _process_converse_response(
self, response: dict[str, Any], options: Mapping[str, Any] | None = None
) -> ChatResponse:
"""Convert Bedrock Converse API response to ChatResponse."""
output = response.get("output") or {}
message = output.get("message") or {}
@@ -646,6 +675,7 @@ class BedrockChatClient(
usage_details=usage_details,
model=model,
finish_reason=finish_reason,
response_format=options.get("response_format") if options else None,
raw_representation=response,
)
@@ -728,6 +758,108 @@ class BedrockChatClient(
return None
return FINISH_REASON_MAP.get(reason.lower())
def _prepare_output_config(self, response_format: Any | None) -> dict[str, Any] | None:
"""Convert response_format into the AWS Bedrock outputConfig wire format.
Args:
response_format: A Pydantic model class or a dict schema, or None.
Returns:
A dict for the Converse API ``outputConfig`` parameter, or None if
response_format is not set.
"""
if response_format is None:
return None
if isinstance(response_format, Mapping):
if "json_schema" in response_format:
# Shape A — OpenAI-style wrapper
json_schema_config = response_format["json_schema"]
schema_src = json_schema_config.get("schema", {})
name = json_schema_config.get("name", "output_schema")
elif "schema" in response_format:
# Shape B — inner shape directly {"name": ..., "schema": ...}
schema_src = response_format["schema"]
name = response_format.get("name", "output_schema")
else:
# Shape C — assume entire dict is the raw schema
logger.warning(
"response_format dict has no 'json_schema' or 'schema' key; "
"treating entire dict as raw JSON schema."
)
schema_src = dict(response_format)
name = "output_schema"
if isinstance(schema_src, str):
schema_src = json.loads(schema_src)
schema = copy.deepcopy(schema_src)
else:
if not isinstance(response_format, type) or not issubclass(response_format, BaseModel):
raise TypeError(
"response_format must be None, a dict JSON schema, "
"or a Pydantic BaseModel subclass."
)
# response_format is a Pydantic model class
schema = response_format.model_json_schema()
name = response_format.__name__
self._set_additional_properties_false(schema)
json_schema: dict[str, Any] = {
"name": name,
"schema": json.dumps(schema),
}
description = getattr(response_format, "__doc__", None) if not isinstance(response_format, Mapping) else None
if description and isinstance(description, str) and description.strip():
json_schema["description"] = description.strip()
return {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": json_schema
},
}
}
def _set_additional_properties_false(self, schema: dict[str, Any]) -> None:
"""Recursively set additionalProperties: false on all object types in a JSON schema.
AWS requires strict schema enforcement. This mirrors the approach used by
AnthropicChatClient._prepare_response_format().
Args:
schema: The JSON schema dict to modify in-place.
"""
visited: set[int] = set()
def walk(node: Any) -> None:
if isinstance(node, dict):
node_id = id(node)
if node_id in visited:
return
visited.add(node_id)
if node.get("type") == "object" or (
"properties" in node and "type" not in node
):
existing = node.get("additionalProperties")
if existing is None or existing is True:
node["additionalProperties"] = False
for value in node.values():
if isinstance(value, (dict, list)):
walk(value)
elif isinstance(node, list):
node_id = id(node)
if node_id in visited:
return
visited.add(node_id)
for item in node:
if isinstance(item, (dict, list)):
walk(item)
walk(schema)
def service_url(self) -> str:
"""Returns the service URL for the Bedrock runtime in the configured AWS region.
@@ -0,0 +1,382 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import copy
import json
from typing import Any
from unittest.mock import patch
import pytest
from agent_framework import Content, Message
from botocore.exceptions import ClientError
from pydantic import BaseModel
from agent_framework_bedrock import BedrockChatClient
# region Test models
class WeatherReport(BaseModel):
city: str
temperature: float
summary: str
class NestedAddress(BaseModel):
street: str
city: str
zip_code: str
class Person(BaseModel):
name: str
age: int
address: NestedAddress
# endregion
# region Helpers
class _StubBedrockRuntime:
"""Stub that records calls and returns a canned response."""
def __init__(self, response_text: str = "Bedrock says hi") -> None:
self.calls: list[dict[str, Any]] = []
self._response_text = response_text
def converse(self, **kwargs: Any) -> dict[str, Any]:
self.calls.append(kwargs)
return {
"modelId": kwargs["modelId"],
"responseId": "resp-structured",
"usage": {"inputTokens": 10, "outputTokens": 20, "totalTokens": 30},
"output": {
"completionReason": "end_turn",
"message": {
"id": "msg-structured",
"role": "assistant",
"content": [{"text": self._response_text}],
},
},
}
def _make_client(response_text: str = "Bedrock says hi") -> tuple[BedrockChatClient, _StubBedrockRuntime]:
stub = _StubBedrockRuntime(response_text)
client = BedrockChatClient(
model="us.anthropic.claude-haiku-4-5-v1:0",
region="us-east-1",
client=stub,
)
return client, stub
def _user_messages() -> list[Message]:
return [Message(role="user", contents=[Content.from_text(text="Give me a weather report")])]
# endregion
# region Tests
def test_prepare_output_config_correct_wire_shape() -> None:
"""_prepare_output_config(WeatherReport) must produce the correct
textFormat → structure → jsonSchema shape with type: 'json_schema'."""
client, _ = _make_client()
output_config = client._prepare_output_config(WeatherReport)
assert output_config is not None
text_format = output_config["textFormat"]
assert text_format["type"] == "json_schema"
assert "structure" in text_format
json_schema = text_format["structure"]["jsonSchema"]
assert json_schema["name"] == "WeatherReport"
assert "schema" in json_schema
def test_prepare_output_config_schema_is_json_string() -> None:
"""The schema value inside jsonSchema must be a JSON string, not a dict."""
client, _ = _make_client()
output_config = client._prepare_output_config(WeatherReport)
assert output_config is not None
schema_value = output_config["textFormat"]["structure"]["jsonSchema"]["schema"]
assert isinstance(schema_value, str), f"Expected str, got {type(schema_value)}"
# Verify it's valid JSON
parsed = json.loads(schema_value)
assert isinstance(parsed, dict)
assert parsed["type"] == "object"
def test_additional_properties_false_set_recursively() -> None:
"""additionalProperties: false must be set on all nested object types."""
client, _ = _make_client()
output_config = client._prepare_output_config(Person)
assert output_config is not None
schema_str = output_config["textFormat"]["structure"]["jsonSchema"]["schema"]
schema = json.loads(schema_str)
# Top-level object
assert schema.get("additionalProperties") is False
# Check $defs for NestedAddress
defs = schema.get("$defs", {})
assert "NestedAddress" in defs, "Expected NestedAddress to be present in $defs"
assert defs["NestedAddress"].get("additionalProperties") is False, (
"Expected additionalProperties=False on nested NestedAddress schema"
)
def test_no_output_config_when_response_format_none() -> None:
"""When response_format is None, no outputConfig key should appear in the request."""
client, stub = _make_client()
messages = _user_messages()
request = client._prepare_options(messages, {"max_tokens": 100})
assert "outputConfig" not in request, (
f"outputConfig should not be present when response_format is None, got: {request.get('outputConfig')}"
)
async def test_chat_response_value_populated() -> None:
"""After a mocked response with response_format, .value should be a populated Pydantic model."""
json_response = json.dumps({"city": "Seattle", "temperature": 72.5, "summary": "Sunny and warm"})
client, stub = _make_client(response_text=json_response)
messages = _user_messages()
response = await client.get_response(
messages=messages,
options={"max_tokens": 100, "response_format": WeatherReport},
)
assert response.text == json_response
assert response.value is not None
assert isinstance(response.value, WeatherReport)
assert response.value.city == "Seattle"
assert response.value.temperature == 72.5
assert response.value.summary == "Sunny and warm"
# Verify outputConfig was sent to the API
assert len(stub.calls) == 1
api_request = stub.calls[0]
assert "outputConfig" in api_request
assert api_request["outputConfig"]["textFormat"]["type"] == "json_schema"
def test_dict_schema_response_format() -> None:
"""_prepare_output_config should work when response_format is a dict, not just a Pydantic class."""
client, _ = _make_client()
dict_schema = {
"json_schema": {
"name": "weather_output",
"schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"temp": {"type": "number"},
},
},
}
}
output_config = client._prepare_output_config(dict_schema)
assert output_config is not None
json_schema = output_config["textFormat"]["structure"]["jsonSchema"]
assert json_schema["name"] == "weather_output"
schema_parsed = json.loads(json_schema["schema"])
assert schema_parsed["type"] == "object"
assert "city" in schema_parsed["properties"]
def test_prepare_output_config_none_returns_none() -> None:
"""_prepare_output_config(None) must return None."""
client, _ = _make_client()
result = client._prepare_output_config(None)
assert result is None
async def test_chat_response_value_populated_streaming() -> None:
"""In streaming mode, .value should also be populated on the final response."""
json_response = json.dumps({"city": "Portland", "temperature": 68.0, "summary": "Cloudy"})
client, stub = _make_client(response_text=json_response)
messages = _user_messages()
stream = client.get_response(
messages=messages,
stream=True,
options={"max_tokens": 100, "response_format": WeatherReport},
)
# Consume stream and get final response
async for _ in stream:
pass
response = await stream.get_final_response()
assert response.value is not None
assert isinstance(response.value, WeatherReport)
assert response.value.city == "Portland"
# Verify outputConfig was sent
assert len(stub.calls) == 1
assert "outputConfig" in stub.calls[0]
async def test_unsupported_model_validation_exception() -> None:
"""When a model doesn't support outputConfig, a clear error should be raised."""
class _FailingStubBedrockRuntime:
def converse(self, **kwargs: Any) -> dict[str, Any]:
# Simulate botocore ClientError for ValidationException
error_response = {"Error": {"Code": "ValidationException", "Message": "Invalid field outputConfig"}}
raise ClientError(error_response, "Converse")
client = BedrockChatClient(
model="us.anthropic.claude-v2",
region="us-east-1",
client=_FailingStubBedrockRuntime(),
)
with pytest.raises(ValueError) as exc:
await client.get_response(
messages=_user_messages(),
options={"response_format": WeatherReport},
)
assert "does not support structured output via outputConfig.textFormat" in str(exc.value)
assert "Check the model's Bedrock Converse outputConfig/textFormat support." in str(exc.value)
def test_invalid_response_format_type_raises() -> None:
"""Non-dict, non-BaseModel response_format should raise TypeError."""
client, _ = _make_client()
with pytest.raises(TypeError, match="Pydantic BaseModel subclass"):
client._prepare_output_config("not_a_valid_format")
def test_mapping_response_format_accepted() -> None:
"""A non-dict Mapping response_format must be accepted and produce
correct outputConfig, not raise TypeError."""
from collections.abc import MutableMapping
class _WrappedMapping(MutableMapping):
def __init__(self, data):
self._data = dict(data)
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def __delitem__(self, key):
del self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
client, _ = _make_client()
mapping_format = _WrappedMapping({
"json_schema": {
"name": "test_output",
"schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
}
})
output_config = client._prepare_output_config(mapping_format)
assert output_config is not None
json_schema = output_config["textFormat"]["structure"]["jsonSchema"]
assert json_schema["name"] == "test_output"
schema = json.loads(json_schema["schema"])
assert schema.get("additionalProperties") is False
def test_shape_b_dict_schema_wire_format() -> None:
"""Dict response_format in Shape B (inner shape directly) should
produce correct outputConfig."""
client, _ = _make_client()
response_format = {
"name": "weather_output",
"schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"temperature": {"type": "number"},
},
},
}
output_config = client._prepare_output_config(response_format)
assert output_config is not None
text_format = output_config["textFormat"]
assert text_format["type"] == "json_schema"
json_schema = text_format["structure"]["jsonSchema"]
assert json_schema["name"] == "weather_output"
schema = json.loads(json_schema["schema"])
assert schema.get("additionalProperties") is False
def test_dict_schema_not_mutated() -> None:
"""Caller's dict schema must not be mutated by _prepare_output_config."""
client, _ = _make_client()
original_schema = {
"json_schema": {
"name": "test",
"schema": {
"type": "object",
"properties": {"a": {"type": "string"}},
},
}
}
snapshot = copy.deepcopy(original_schema)
client._prepare_output_config(original_schema)
assert original_schema == snapshot, "Original dict schema was mutated"
async def test_non_outputconfig_validation_exception_propagates() -> None:
"""ValidationException unrelated to outputConfig must propagate
as raw ClientError, not be caught and reclassified."""
client, _ = _make_client()
error_response = {
"Error": {
"Code": "ValidationException",
"Message": "Invalid message format",
}
}
with (
patch.object(
client,
"_bedrock_client",
**{"converse.side_effect": ClientError(error_response, "Converse")},
),
pytest.raises(ClientError),
):
await client.get_response(
messages=_user_messages(),
options={"max_tokens": 100},
)
# endregion
@@ -71,6 +71,7 @@ from ._evaluation import (
Evaluator,
ExpectedToolCall,
LocalEvaluator,
RubricScore,
evaluate_agent,
evaluate_workflow,
evaluator,
@@ -460,6 +461,7 @@ __all__ = [
"ResponseStream",
"Role",
"RoleLiteral",
"RubricScore",
"RunContext",
"Runner",
"RunnerContext",
@@ -311,12 +311,15 @@ class EvalScoreResult:
score: Numeric score from the evaluator.
passed: Whether the item passed this evaluator's threshold.
sample: Optional raw evaluator output (rationale, metadata).
dimensions: Per-dimension scores when this evaluator is a rubric
evaluator. ``None`` for non-rubric (e.g. built-in) evaluators.
"""
name: str
score: float
passed: bool | None = None
sample: dict[str, Any] | None = None
dimensions: list[RubricScore] | None = None
@experimental(feature_id=ExperimentalFeature.EVALS)
@@ -496,6 +499,179 @@ class EvalResults:
detail += f" Errored items: {', '.join(summaries)}."
raise EvalNotPassedError(detail)
def assert_score_at_least(
self,
min_score: float,
*,
evaluator: str | None = None,
msg: str | None = None,
) -> None:
"""Assert every item's score (optionally filtered by evaluator) is ``>= min_score``.
Designed for CI gates on generated rubric evaluators (e.g.
``results.assert_score_at_least(0.80)``). Includes any
sub-results from workflow evaluations.
Args:
min_score: Minimum acceptable score (inclusive).
evaluator: When set, only check scores from the evaluator
whose ``EvalScoreResult.name`` matches.
msg: Optional custom failure message.
Raises:
EvalNotPassedError: When any matching score is below the threshold.
"""
offenders: list[str] = []
def _check(results: EvalResults) -> None:
for item in results.items:
for score in item.scores:
if evaluator is not None and score.name != evaluator:
continue
if score.score < min_score:
offenders.append(f"{item.item_id}/{score.name}={score.score:.3f}")
for sub in results.sub_results.values():
_check(sub)
_check(self)
if offenders:
detail = msg or (
f"{len(offenders)} score(s) below threshold {min_score}"
f"{' for ' + evaluator if evaluator else ''}: {', '.join(offenders[:5])}"
+ (f" (+{len(offenders) - 5} more)" if len(offenders) > 5 else "")
)
raise EvalNotPassedError(detail)
def assert_dimension_score_at_least(
self,
dimension_id: str,
min_score: float,
*,
evaluator: str | None = None,
require_applicable: bool = False,
msg: str | None = None,
) -> None:
"""Assert every item's score for a rubric *dimension* is ``>= min_score``.
Walks ``EvalScoreResult.dimensions`` looking for the named
dimension across all items (and sub-results). Non-applicable
dimensions are skipped by default; pass
``require_applicable=True`` to fail when no applicable score is
produced.
Args:
dimension_id: Dimension id (matches the rubric definition).
min_score: Minimum acceptable dimension score (inclusive).
evaluator: When set, only consider scores from the evaluator
whose ``EvalScoreResult.name`` matches.
require_applicable: When ``True``, missing or non-applicable
dimension scores raise. Defaults to ``False`` (skip).
msg: Optional custom failure message.
Raises:
EvalNotPassedError: When the dimension fails the threshold.
"""
offenders: list[str] = []
missing_items: list[str] = []
def _check(results: EvalResults) -> None:
for item in results.items:
found_applicable = False
for score in item.scores:
if evaluator is not None and score.name != evaluator:
continue
if not score.dimensions:
continue
for rs in score.dimensions:
if rs.id != dimension_id:
continue
if not rs.applicable:
continue
found_applicable = True
if rs.score is None or rs.score < min_score:
offenders.append(
f"{item.item_id}/{score.name}/{dimension_id}="
f"{rs.score if rs.score is not None else 'None'}"
)
if require_applicable and not found_applicable:
missing_items.append(item.item_id)
for sub in results.sub_results.values():
_check(sub)
_check(self)
problems: list[str] = []
if offenders:
problems.append(
f"{len(offenders)} dimension score(s) for '{dimension_id}' below {min_score}: "
f"{', '.join(offenders[:5])}" + (f" (+{len(offenders) - 5} more)" if len(offenders) > 5 else "")
)
if missing_items:
problems.append(
f"Dimension '{dimension_id}' not applicable on {len(missing_items)} item(s): "
f"{', '.join(missing_items[:5])}"
)
if problems:
raise EvalNotPassedError(msg or "; ".join(problems))
def assert_no_failed_items(self, msg: str | None = None) -> None:
"""Assert no item ended in ``fail`` or ``error`` status.
Includes any sub-results from workflow evaluations.
Args:
msg: Optional custom failure message.
Raises:
EvalNotPassedError: When any item failed or errored.
"""
bad: list[str] = []
def _check(results: EvalResults) -> None:
for item in results.items:
if item.is_failed or item.is_error:
bad.append(f"{item.item_id}:{item.status}")
for sub in results.sub_results.values():
_check(sub)
_check(self)
if bad:
detail = msg or (
f"{len(bad)} item(s) failed or errored: {', '.join(bad[:5])}"
+ (f" (+{len(bad) - 5} more)" if len(bad) > 5 else "")
)
raise EvalNotPassedError(detail)
# endregion
# region Generated rubric evaluators
@experimental(feature_id=ExperimentalFeature.EVALS)
@dataclass(frozen=True)
class RubricScore:
"""A single dimension's score from a rubric-based evaluator run.
Rubric evaluators emit one ``RubricScore`` per dimension per item.
Attached to :class:`EvalScoreResult` as a typed view of the raw
``properties.rubric_scores`` payload returned by providers such as
Foundry's generated rubric evaluators.
Attributes:
id: Dimension id (matches the rubric definition).
score: Numeric score, or ``None`` when the dimension was marked
non-applicable for this item.
applicable: Whether the dimension applied to this item.
weight: Dimension weight (mirrors the rubric definition).
reason: Short rationale produced by the evaluator.
"""
id: str
score: int | None
applicable: bool
weight: int
reason: str
# endregion
@@ -50,6 +50,7 @@ class ExperimentalFeature(str, Enum):
on enum membership or attribute presence over time.
"""
DECLARATIVE_AGENTS = "DECLARATIVE_AGENTS"
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
FIDES = "FIDES"
@@ -14,12 +14,13 @@ import logging
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any
from .._agents import Agent
from .._agents import Agent, SupportsAgentRun
from .._clients import SupportsWebSearchTool
from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy
from .._feature_stage import ExperimentalFeature, experimental
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider
from .._skills import SkillsProvider
from ._background_agents import BackgroundAgentsProvider
from ._memory import MemoryContextProvider, MemoryStore
from ._mode import AgentModeProvider
from ._todo import TodoProvider
@@ -103,6 +104,8 @@ def _assemble_context_providers(
memory_store: MemoryStore | None,
skills_provider: SkillsProvider | None,
skills_paths: Sequence[str] | None,
background_agents: Sequence[SupportsAgentRun] | None,
background_agents_instructions: str | None,
extra_context_providers: Sequence[ContextProvider] | None,
) -> list[ContextProvider]:
"""Assemble the ordered list of context providers."""
@@ -130,6 +133,10 @@ def _assemble_context_providers(
if skills_paths:
providers.append(SkillsProvider.from_paths(*skills_paths))
# Background agents are opt-in: only added when agents are provided.
if background_agents:
providers.append(BackgroundAgentsProvider(background_agents, instructions=background_agents_instructions))
# Append any user-supplied additional providers.
if extra_context_providers:
providers.extend(extra_context_providers)
@@ -165,6 +172,8 @@ def create_harness_agent(
memory_store: MemoryStore | None = None,
skills_provider: SkillsProvider | None = None,
skills_paths: Sequence[str] | None = None,
background_agents: Sequence[SupportsAgentRun] | None = None,
background_agents_instructions: str | None = None,
disable_web_search: bool = False,
otel_provider_name: str | None = None,
context_providers: Sequence[ContextProvider] | None = None,
@@ -182,6 +191,7 @@ def create_harness_agent(
- **AgentModeProvider** — plan/execute mode tracking
- **MemoryContextProvider** — file-based durable memory (when ``memory_store`` provided)
- **SkillsProvider** — skill discovery and progressive loading
- **BackgroundAgentsProvider** — delegate work to background sub-agents
- **OpenTelemetry** — observability via ``AgentTelemetryLayer``
Each feature can be disabled or customized via keyword arguments.
@@ -253,6 +263,13 @@ def create_harness_agent(
skills_paths: Paths for file-based skill discovery (looks for SKILL.md files).
Can be combined with ``skills_provider``. When neither ``skills_provider``
nor ``skills_paths`` is provided, no SkillsProvider is added.
background_agents: Collection of agents available for background task delegation.
When provided, a ``BackgroundAgentsProvider`` is automatically included,
enabling the agent to start, monitor, and retrieve results from background tasks.
Each agent must have a non-empty, unique name (case-insensitive).
background_agents_instructions: Optional instruction override for the
``BackgroundAgentsProvider``. May include ``{background_agents}`` placeholder
which will be replaced with the agent listing.
disable_web_search: When True, skip automatic web search tool inclusion.
When False (default), the web search tool is automatically added if the
client implements SupportsWebSearchTool. A warning is logged if the client
@@ -302,6 +319,8 @@ def create_harness_agent(
memory_store=memory_store,
skills_provider=skills_provider,
skills_paths=skills_paths,
background_agents=background_agents,
background_agents_instructions=background_agents_instructions,
extra_context_providers=context_providers,
)
@@ -36,11 +36,10 @@ if TYPE_CHECKING:
from pydantic import BaseModel
from ._agents import SupportsAgentRun
from ._clients import SupportsChatGetResponse
from ._compaction import CompactionStrategy, TokenizerProtocol
from ._sessions import AgentSession
from ._tools import FunctionTool, ToolTypes
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
from ._types import ChatOptions
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
@@ -7,6 +7,8 @@ import json
import logging
import re
from collections.abc import Mapping, MutableMapping
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable
logger = logging.getLogger("agent_framework")
@@ -614,3 +616,46 @@ class SerializationMixin:
# Fallback and default
# Convert class name to snake_case
return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower()
def make_json_safe(obj: Any) -> Any:
"""Recursively convert an object to a JSON-serializable form.
Handles dataclasses, Pydantic models, objects with ``to_dict``/``dict``/``__dict__``,
datetimes, lists, dicts, and primitives. Falls back to ``str()`` for any remaining
non-serializable value so that ``json.dumps`` never raises a ``TypeError``.
Args:
obj: Object to make JSON safe.
Returns:
A JSON-serializable version of the object.
"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if is_dataclass(obj) and not isinstance(obj, type):
return make_json_safe(asdict(obj)) # type: ignore[arg-type]
if callable(getattr(obj, "model_dump", None)):
try:
return make_json_safe(obj.model_dump()) # type: ignore[no-any-return]
except TypeError:
pass
if callable(getattr(obj, "to_dict", None)):
try:
return make_json_safe(obj.to_dict()) # type: ignore[no-any-return]
except TypeError:
pass
if callable(getattr(obj, "dict", None)):
try:
return make_json_safe(obj.dict()) # type: ignore[no-any-return]
except TypeError:
pass
if isinstance(obj, dict):
return {str(key): make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
if isinstance(obj, (list, tuple)):
return [make_json_safe(item) for item in obj] # type: ignore[misc]
if hasattr(obj, "__dict__"):
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
return str(obj)
@@ -2134,9 +2134,7 @@ class SkillsProvider(ContextProvider):
),
FunctionTool(
name="read_skill_resource",
description=(
"Reads a resource associated with a skill, such as references, assets, or dynamic data."
),
description=("Reads a resource associated with a skill, such as references, assets, or dynamic data."),
func=_read_resource,
input_model={
"type": "object",
@@ -2173,8 +2171,7 @@ class SkillsProvider(ContextProvider):
"type": "object",
"additionalProperties": True,
"description": (
"Named arguments as key-value pairs "
'(e.g. {"length": 24, "uppercase": true}).'
'Named arguments as key-value pairs (e.g. {"length": 24, "uppercase": true}).'
),
},
{
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload
from .._agents import BaseAgent
from .._serialization import make_json_safe
from .._sessions import (
AgentSession,
ContextProvider,
@@ -61,7 +62,7 @@ class WorkflowAgent(BaseAgent):
data: Any
def to_dict(self) -> dict[str, Any]:
return {"request_id": self.request_id, "data": self.data}
return {"request_id": self.request_id, "data": make_json_safe(self.data)}
def to_json(self) -> str:
return json.dumps(self.to_dict())
@@ -13,6 +13,35 @@ during deserialization. The default built-in safe set covers common Python
value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal
types, and all ``openai.types`` types. Callers can extend the set by passing
additional ``"module:qualname"`` strings.
Security Model
--------------
Checkpoint storage is treated as a **trusted data source**. The serialization
format uses Python's ``pickle`` module which can execute arbitrary code during
deserialization. The ``RestrictedUnpickler`` provides a defense-in-depth
allowlist that limits instantiable classes, but it is **not** a security
boundary — certain allowlisted builtins (e.g. ``getattr``) are required for
legitimate object reconstruction (enums, named tuples) and cannot be removed
without breaking compatibility.
Developers **must** ensure that:
1. The checkpoint storage backend (file system, Cosmos DB, Azure Blob, Durable
Functions storage) is access-controlled and not writable by untrusted
parties.
2. Data flowing into ``decode_checkpoint_value`` originates exclusively from
the application's own checkpoint storage — never from user-supplied HTTP
requests, message payloads, or other untrusted sources.
3. The ``allowed_types`` parameter is specified whenever possible to restrict
the set of reconstructible types to the minimum required by the application.
4. Never pass untrusted external input to ``decode_checkpoint_value``. If you
must accept external JSON that might contain checkpoint markers, sanitize it
first (for example, :func:`agent_framework_azurefunctions._serialization.strip_pickle_markers`).
The allowlist is a mitigation that reduces attack surface but does not
eliminate the inherent risks of deserializing untrusted pickle data. Treat
your checkpoint storage with the same access controls you would apply to
application secrets or database credentials.
"""
from __future__ import annotations
@@ -47,6 +47,7 @@ from copy import deepcopy
from typing import Any, Generic, Literal, TypeVar, overload
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import make_json_safe
from .._types import AgentResponse, AgentResponseUpdate, ResponseStream
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
@@ -1515,7 +1516,7 @@ class FunctionalWorkflowAgent:
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments={"request_id": request_id, "data": event.data},
arguments={"request_id": request_id, "data": make_json_safe(event.data)},
)
return Content.from_function_approval_request(
id=request_id,
@@ -34,6 +34,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
"FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
"FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"),
"GeneratedEvaluatorRef": ("agent_framework_foundry", "agent-framework-foundry"),
"RawAnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
"RawFoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
"RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
@@ -20,6 +20,7 @@ from agent_framework_foundry import (
FoundryEmbeddingSettings,
FoundryEvals,
FoundryMemoryProvider,
GeneratedEvaluatorRef,
RawFoundryAgent,
RawFoundryAgentChatClient,
RawFoundryChatClient,
@@ -52,6 +53,7 @@ __all__ = [
"FoundryLocalClient",
"FoundryLocalSettings",
"FoundryMemoryProvider",
"GeneratedEvaluatorRef",
"RawAnthropicFoundryClient",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
@@ -498,14 +498,34 @@ def _get_exporters_from_env(
# Get base endpoint
base_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
# Get signal-specific endpoints (these override base endpoint)
traces_endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or base_endpoint
metrics_endpoint = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or base_endpoint
logs_endpoint = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") or base_endpoint
# Get signal-specific endpoints (these override base endpoint and are used verbatim)
traces_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
metrics_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
logs_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
# Get protocol (default is grpc)
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower()
# Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK
# auto-appends /v1/{traces,metrics,logs} when it reads the env var directly. The
# signal-specific endpoint env vars are *full* URLs used verbatim. Because we read
# the env vars here and forward them as the ``endpoint=`` constructor argument
# (which the SDK always treats as a full URL), we must replicate the auto-append
# ourselves for HTTP when falling back to the base endpoint. For gRPC, the base
# endpoint is used as-is.
traces_endpoint: str | None
metrics_endpoint: str | None
logs_endpoint: str | None
if protocol in ("http/protobuf", "http") and base_endpoint:
base_for_http = base_endpoint.rstrip("/")
traces_endpoint = traces_endpoint_specific or f"{base_for_http}/v1/traces"
metrics_endpoint = metrics_endpoint_specific or f"{base_for_http}/v1/metrics"
logs_endpoint = logs_endpoint_specific or f"{base_for_http}/v1/logs"
else:
traces_endpoint = traces_endpoint_specific or base_endpoint
metrics_endpoint = metrics_endpoint_specific or base_endpoint
logs_endpoint = logs_endpoint_specific or base_endpoint
# Get base headers
base_headers_str = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "")
base_headers = _parse_headers(base_headers_str)
@@ -394,3 +394,94 @@ def test_create_harness_agent_logs_warning_when_no_web_search(caplog: pytest.Log
max_output_tokens=16_384,
)
assert any("SupportsWebSearchTool" in msg for msg in caplog.messages)
# --- Background Agents Tests ---
class _FakeBackgroundAgent:
"""Minimal agent stub satisfying SupportsAgentRun for background agents tests."""
def __init__(self, name: str, description: str | None = None):
self.id = f"agent-{name}"
self.name = name
self.description = description
def create_session(self, *, session_id: str | None = None) -> AgentSession:
return AgentSession(session_id=session_id)
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
return AgentSession(service_session_id=service_session_id, session_id=session_id)
async def run(self, messages: Any = None, *, stream: bool = False, session: Any = None, **kwargs: Any) -> Any:
from agent_framework import AgentResponse
return AgentResponse(messages=[], response_id="fake-bg-response")
def test_create_harness_agent_no_background_agents_by_default() -> None:
"""No BackgroundAgentsProvider should be included when background_agents is not provided."""
from agent_framework._harness._background_agents import BackgroundAgentsProvider
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=128_000,
max_output_tokens=16_384,
disable_web_search=True,
)
providers = agent.context_providers or []
assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers)
def test_create_harness_agent_adds_background_agents_provider() -> None:
"""BackgroundAgentsProvider should be included when background_agents are provided."""
from agent_framework._harness._background_agents import BackgroundAgentsProvider
bg_agent = _FakeBackgroundAgent("WebSearcher", "Searches the web")
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=128_000,
max_output_tokens=16_384,
disable_web_search=True,
background_agents=[bg_agent],
)
providers = agent.context_providers or []
bg_providers = [p for p in providers if isinstance(p, BackgroundAgentsProvider)]
assert len(bg_providers) == 1
def test_create_harness_agent_background_agents_custom_instructions() -> None:
"""Custom instructions should be passed to BackgroundAgentsProvider."""
from agent_framework._harness._background_agents import BackgroundAgentsProvider
custom_instructions = "## Custom\n\nUse agents wisely.\n\n{background_agents}"
bg_agent = _FakeBackgroundAgent("Helper", "A helper agent")
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=128_000,
max_output_tokens=16_384,
disable_web_search=True,
background_agents=[bg_agent],
background_agents_instructions=custom_instructions,
)
providers = agent.context_providers or []
bg_providers = [p for p in providers if isinstance(p, BackgroundAgentsProvider)]
assert len(bg_providers) == 1
# Verify the custom instructions were used (placeholder replaced with agent list).
assert "Custom" in bg_providers[0]._instructions
assert "Helper" in bg_providers[0]._instructions
def test_create_harness_agent_empty_background_agents_list() -> None:
"""An empty background_agents list should NOT add a BackgroundAgentsProvider."""
from agent_framework._harness._background_agents import BackgroundAgentsProvider
agent = create_harness_agent(
client=_FakeChatClient(), # type: ignore[arg-type]
max_context_window_tokens=128_000,
max_output_tokens=16_384,
disable_web_search=True,
background_agents=[],
)
providers = agent.context_providers or []
assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers)
@@ -11,8 +11,13 @@ import pytest
from agent_framework._evaluation import (
CheckResult,
EvalItem,
EvalItemResult,
EvalNotPassedError,
EvalResults,
EvalScoreResult,
ExpectedToolCall,
LocalEvaluator,
RubricScore,
_coerce_result,
evaluator,
keyword_check,
@@ -1010,19 +1015,101 @@ class TestAllPassedSubResults:
# ---------------------------------------------------------------------------
# r5 review: _build_overall_item with empty outputs
# Rubric assertions (EvalResults.assert_*)
# ---------------------------------------------------------------------------
class TestBuildOverallItemEmpty:
"""Test _build_overall_item returns None for empty workflow outputs."""
def _rubric_results(*scores_per_item: list[EvalScoreResult]) -> EvalResults:
items = [
EvalItemResult(item_id=f"item-{i}", status="pass", scores=scores) for i, scores in enumerate(scores_per_item)
]
return EvalResults(
provider="test",
eval_id="ev1",
run_id="run1",
result_counts={"passed": len(items), "failed": 0, "errored": 0, "total": len(items)},
items=items,
)
def test_returns_none_for_empty_outputs(self):
from unittest.mock import MagicMock
from agent_framework._evaluation import _build_overall_item
class TestRubricAssertions:
"""Tests for EvalResults.assert_dimension_score_at_least."""
mock_result = MagicMock()
mock_result.get_outputs.return_value = []
item = _build_overall_item("Hello", mock_result)
assert item is None
def test_dimension_at_or_above_threshold_passes(self) -> None:
results = _rubric_results(
[
EvalScoreResult(
name="policy",
score=0.9,
dimensions=[RubricScore(id="clarity", score=4, applicable=True, weight=1, reason="")],
)
],
)
# Should not raise.
results.assert_dimension_score_at_least("clarity", 3)
def test_dimension_below_threshold_raises(self) -> None:
results = _rubric_results(
[
EvalScoreResult(
name="policy",
score=0.5,
dimensions=[RubricScore(id="clarity", score=2, applicable=True, weight=1, reason="")],
)
],
)
with pytest.raises(EvalNotPassedError):
results.assert_dimension_score_at_least("clarity", 3)
def test_non_applicable_skipped_by_default(self) -> None:
results = _rubric_results(
[
EvalScoreResult(
name="policy",
score=1.0,
dimensions=[RubricScore(id="clarity", score=None, applicable=False, weight=1, reason="n/a")],
)
],
)
# No applicable scores; default behaviour is to skip silently.
results.assert_dimension_score_at_least("clarity", 3)
def test_require_applicable_raises_when_dimension_absent(self) -> None:
results = _rubric_results(
[EvalScoreResult(name="policy", score=1.0, dimensions=[])],
)
with pytest.raises(EvalNotPassedError, match="not applicable"):
results.assert_dimension_score_at_least("clarity", 3, require_applicable=True)
def test_require_applicable_raises_when_filtered_evaluator_missing(self) -> None:
# Regression: previously the (not evaluator or found_any) guard caused
# this case to silently pass even with require_applicable=True.
results = _rubric_results(
[
EvalScoreResult(
name="other",
score=0.9,
dimensions=[RubricScore(id="clarity", score=4, applicable=True, weight=1, reason="")],
)
],
)
with pytest.raises(EvalNotPassedError, match="not applicable"):
results.assert_dimension_score_at_least("clarity", 3, evaluator="policy", require_applicable=True)
def test_evaluator_filter_isolates_offenders(self) -> None:
results = _rubric_results(
[
EvalScoreResult(
name="other",
score=0.1,
dimensions=[RubricScore(id="clarity", score=1, applicable=True, weight=1, reason="")],
),
EvalScoreResult(
name="policy",
score=0.9,
dimensions=[RubricScore(id="clarity", score=4, applicable=True, weight=1, reason="")],
),
],
)
# The low-scoring "other" evaluator is filtered out; "policy" passes.
results.assert_dimension_score_at_least("clarity", 3, evaluator="policy")
@@ -761,6 +761,115 @@ def test_get_exporters_from_env_missing_grpc_dependency(monkeypatch):
_get_exporters_from_env()
# region Test OTLP endpoint computation (base-URL auto-append for HTTP)
def test_get_exporters_from_env_http_base_endpoint_appends_signal_paths(monkeypatch):
"""OTEL_EXPORTER_OTLP_ENDPOINT is a base URL for HTTP; SDK auto-appends
/v1/{traces,metrics,logs}. Because we read the env var and forward it as the
constructor ``endpoint=`` arg (which the SDK treats as a full URL), we must
replicate the auto-append ourselves.
"""
from unittest.mock import patch
from agent_framework import observability
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
for key in (
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
):
monkeypatch.delenv(key, raising=False)
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
observability._get_exporters_from_env()
kwargs = create.call_args.kwargs
assert kwargs["protocol"] == "http/protobuf"
assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces"
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
def test_get_exporters_from_env_http_base_endpoint_trailing_slash(monkeypatch):
"""A trailing slash on the base endpoint should not produce a doubled slash."""
from unittest.mock import patch
from agent_framework import observability
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
for key in (
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
):
monkeypatch.delenv(key, raising=False)
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
observability._get_exporters_from_env()
kwargs = create.call_args.kwargs
assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces"
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
def test_get_exporters_from_env_http_signal_specific_used_verbatim(monkeypatch):
"""Signal-specific endpoint env vars are full URLs and must be used verbatim,
even when a base endpoint is also set.
"""
from unittest.mock import patch
from agent_framework import observability
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://traces.example.com/custom/path")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
for key in (
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
):
monkeypatch.delenv(key, raising=False)
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
observability._get_exporters_from_env()
kwargs = create.call_args.kwargs
# Signal-specific is verbatim — no path appended
assert kwargs["traces_endpoint"] == "http://traces.example.com/custom/path"
# Others fall back to base, with path appended
assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics"
assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs"
def test_get_exporters_from_env_grpc_base_endpoint_unchanged(monkeypatch):
"""For gRPC, the base endpoint applies to all signals as-is (no path append)."""
from unittest.mock import patch
from agent_framework import observability
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
for key in (
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
):
monkeypatch.delenv(key, raising=False)
with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create:
observability._get_exporters_from_env()
kwargs = create.call_args.kwargs
assert kwargs["protocol"] == "grpc"
assert kwargs["traces_endpoint"] == "http://localhost:4317"
assert kwargs["metrics_endpoint"] == "http://localhost:4317"
assert kwargs["logs_endpoint"] == "http://localhost:4317"
# region Test create_resource
@@ -1691,6 +1800,65 @@ def test_to_otel_part_function_call():
}
def test_to_otel_part_function_call_reuses_prepared_arguments():
"""Test _to_otel_part does not re-serialize function-call arguments in the observability hot path."""
from agent_framework import Content
from agent_framework.observability import _to_otel_part
arguments = {"payload": object()}
content = Content(type="function_call", call_id="call_789", name="handoff", arguments=arguments)
result = _to_otel_part(content)
assert result is not None
assert result["arguments"] is arguments
def test_make_json_safe_non_callable_method_attribute():
"""Test make_json_safe handles objects where model_dump/to_dict/dict are non-callable attributes."""
from agent_framework._serialization import make_json_safe
class ObjWithNonCallableModelDump:
model_dump = 42 # not callable
obj = ObjWithNonCallableModelDump()
result = make_json_safe(obj)
assert result == {}
def test_make_json_safe_callable_method_type_error_falls_through():
"""Test make_json_safe falls through when serializer-like methods require arguments."""
from agent_framework._serialization import make_json_safe
class ObjWithRequiredArgModelDump:
def __init__(self) -> None:
self.value = "fallback"
def model_dump(self, required: str) -> dict[str, str]:
return {"required": required}
obj = ObjWithRequiredArgModelDump()
result = make_json_safe(obj)
assert result == {"value": "fallback"}
def test_make_json_safe_dict_with_non_string_keys():
"""Test make_json_safe converts non-primitive dict keys to strings."""
import json
from datetime import datetime
from agent_framework._serialization import make_json_safe
dt_key = datetime(2024, 1, 1)
obj = {dt_key: "value", 42: "num_value", "str_key": "normal"}
result = make_json_safe(obj)
# json.dumps must not raise TypeError
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed[str(dt_key)] == "value"
assert parsed["42"] == "num_value"
assert parsed["str_key"] == "normal"
def test_to_otel_part_function_result():
"""Test _to_otel_part with function_result content."""
from agent_framework import Content
@@ -3019,6 +3187,49 @@ async def test_system_instructions_preserves_non_ascii_characters(span_exporter:
assert [msg.get("role") for msg in input_messages] == ["user"]
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
def test_capture_messages_with_prepared_request_info_function_call_arguments(span_exporter: InMemorySpanExporter):
"""Test _capture_messages handles request-info function-call arguments prepared at Content creation."""
import dataclasses
import json
from opentelemetry import trace
from agent_framework import WorkflowAgent
@dataclasses.dataclass
class HandoffRequest:
target_agent: str
reason: str
arguments = WorkflowAgent.RequestInfoFunctionArgs(
request_id="call_dc",
data=HandoffRequest(target_agent="helper", reason="overflow"),
).to_dict()
msg = Message(
role="assistant",
contents=[
Content(
type="function_call",
call_id="call_dc",
name="request_info",
arguments=arguments,
)
],
)
span_exporter.clear()
tracer = trace.get_tracer("test")
with tracer.start_as_current_span("test_span") as span:
_capture_messages(span=span, provider_name="test_provider", messages=[msg])
spans = span_exporter.get_finished_spans()
span = spans[0]
input_messages = json.loads(span.attributes[OtelAttr.INPUT_MESSAGES])
tool_part = input_messages[0]["parts"][0]
assert tool_part["type"] == "tool_call"
assert tool_part["arguments"]["data"] == {"target_agent": "helper", "reason": "overflow"}
def test_capture_messages_keeps_framework_instructions_out_of_logs_and_span_messages(
span_exporter: InMemorySpanExporter,
):
@@ -4086,8 +4086,8 @@ class TestClassSkill:
async def test_content_is_cached(self) -> None:
skill = _MinimalClassSkill()
content1 = (await skill.get_content())
content2 = (await skill.get_content())
content1 = await skill.get_content()
content2 = await skill.get_content()
assert content1 is content2
def test_resources_are_lazy_cached(self) -> None:
@@ -5587,8 +5587,8 @@ class TestInlineSkillContentCaching:
async def test_content_cached_after_first_access(self) -> None:
"""InlineSkill.content returns the same object on subsequent accesses."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="test-skill", description="Test"), instructions="Body")
first = (await skill.get_content())
second = (await skill.get_content())
first = await skill.get_content()
second = await skill.get_content()
assert first is second # Same object (cached)
assert "<name>test-skill</name>" in first
@@ -5,6 +5,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterator
from contextlib import contextmanager
@@ -1642,6 +1643,37 @@ class TestFunctionalWorkflowAgentHITL:
break
assert approval_found, "expected FunctionApprovalRequestContent in agent response"
async def test_request_info_dataclass_arguments_are_serialized_for_agent(self):
@dataclass
class HandoffRequest:
target_agent: str
reason: str
@workflow
async def wf(x: str, ctx: RunContext) -> str:
answer = await ctx.request_info(
HandoffRequest(target_agent=x, reason="overflow"),
response_type=str,
request_id="rid-1",
)
return f"got:{answer}"
agent = wf.as_agent()
response = await agent.run("helper")
function_call_arguments = None
for message in response.messages:
for content in message.contents:
if getattr(content, "type", None) == "function_approval_request" and content.function_call is not None:
function_call_arguments = content.function_call.arguments
break
assert function_call_arguments == {
"request_id": "rid-1",
"data": {"target_agent": "helper", "reason": "overflow"},
}
assert json.loads(json.dumps(function_call_arguments)) == function_call_arguments
async def test_resume_via_agent_responses_kwarg(self):
@workflow
async def wf(x: str, ctx: RunContext) -> str:
@@ -1,7 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import uuid
from collections.abc import Awaitable, Sequence
from dataclasses import dataclass
from typing import Any, Literal, overload
import pytest
@@ -23,6 +25,7 @@ from agent_framework import (
WorkflowAgent,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
handler,
response_handler,
@@ -293,6 +296,33 @@ class TestWorkflowAgent:
# Verify cleanup - pending requests should be cleared after function response handling
assert len(agent.pending_requests) == 0
def test_request_info_dataclass_arguments_are_serialized_when_content_is_created(self) -> None:
"""Test WorkflowAgent prepares request_info arguments before observability captures messages."""
@dataclass
class HandoffRequest:
target_agent: str
reason: str
executor = SimpleExecutor(id="executor1", response_text="Response")
workflow = WorkflowBuilder(start_executor=executor).build()
agent = WorkflowAgent(workflow=workflow, name="Request Test Agent")
event = WorkflowEvent.request_info(
request_id="request_123",
source_executor_id="executor1",
request_data=HandoffRequest(target_agent="helper", reason="overflow"),
response_type=str,
)
function_call, approval_request = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage]
assert function_call.arguments == {
"request_id": "request_123",
"data": {"target_agent": "helper", "reason": "overflow"},
}
assert approval_request.function_call is function_call
assert json.loads(json.dumps(function_call.arguments)) == function_call.arguments
def test_workflow_as_agent_method(self) -> None:
"""Test that Workflow.as_agent() creates a properly configured WorkflowAgent."""
# Create a simple workflow
+12
View File
@@ -6,6 +6,18 @@ Please install this package via pip:
pip install agent-framework-declarative --pre
```
## Release stage
This package ships at two different stability levels:
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
`_workflows` surface) are at **release-candidate** stability and may receive only
minor refinements before GA.
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
**experimental** and may change or be removed in future versions without notice.
Using any of these symbols emits an `ExperimentalWarning` on first use.
## Declarative features
The declarative packages provides support for building agents based on a declarative yaml specification.
@@ -1,5 +1,18 @@
# Copyright (c) Microsoft. All rights reserved.
"""Declarative specification support for Microsoft Agent Framework.
Release stage:
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
etc.) is at release-candidate stability.
* The declarative-agents surface (``AgentFactory`` and the YAML agent
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
``ProviderTypeMapping``) is *experimental* and may change or be removed in
future versions without notice. Using these symbols emits an
``ExperimentalWarning`` on first use.
"""
from importlib import metadata
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
@@ -15,6 +15,10 @@ from agent_framework import (
from agent_framework import (
FunctionTool as AFFunctionTool,
)
from agent_framework._feature_stage import ( # type: ignore[reportPrivateUsage]
ExperimentalFeature,
experimental,
)
from agent_framework.exceptions import AgentException
from dotenv import load_dotenv
@@ -43,6 +47,7 @@ else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderTypeMapping(TypedDict, total=True):
package: str
name: str
@@ -118,18 +123,21 @@ PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
}
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class DeclarativeLoaderError(AgentException):
"""Exception raised for errors in the declarative loader."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderLookupError(DeclarativeLoaderError):
"""Exception raised for errors in provider type lookup."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class AgentFactory:
"""Factory for creating Agent instances from declarative YAML definitions.
+4 -3
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260528"
version = "1.0.0rc1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -29,7 +29,7 @@ dependencies = [
]
[dependency-groups]
dev = [
"types-PyYaml==6.0.12.20250915"
"types-PyYaml==6.0.12.20260518"
]
[tool.uv]
@@ -49,7 +49,8 @@ addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*",
"ignore::agent_framework._feature_stage.ExperimentalWarning",
]
timeout = 120
markers = [
@@ -375,13 +375,15 @@ class DevServer:
logger.info("Starting Agent Framework Server")
await self._ensure_executor()
await self._ensure_openai_executor() # Initialize OpenAI executor
yield
# Shutdown
logger.info("Shutting down Agent Framework Server")
try:
yield
finally:
# Shutdown
logger.info("Shutting down Agent Framework Server")
# Cleanup entity resources (e.g., close credentials, clients)
if self.executor:
await self._cleanup_entities()
# Cleanup entity resources (e.g., close credentials, clients)
if self.executor:
await self._cleanup_entities()
app = FastAPI(
title="Agent Framework Server",
+1 -1
View File
@@ -30,7 +30,7 @@ dependencies = [
[dependency-groups]
dev = [
"types-python-dateutil==2.9.0.20260402",
"types-python-dateutil==2.9.0.20260518",
]
[tool.uv]
@@ -12,6 +12,7 @@ from ._embedding_client import (
)
from ._foundry_evals import (
FoundryEvals,
GeneratedEvaluatorRef,
evaluate_foundry_target,
evaluate_traces,
)
@@ -33,6 +34,7 @@ __all__ = [
"FoundryEmbeddingSettings",
"FoundryEvals",
"FoundryMemoryProvider",
"GeneratedEvaluatorRef",
"RawFoundryAgent",
"RawFoundryAgentChatClient",
"RawFoundryChatClient",
@@ -57,8 +57,6 @@ if TYPE_CHECKING:
from agent_framework import (
Agent,
AgentRunInputs,
ChatAndFunctionMiddlewareTypes,
ContextProvider,
MiddlewareTypes,
ToolTypes,
)
@@ -353,6 +351,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
if _uses_foundry_agent_session(conversation_id):
run_options.pop("previous_response_id", None)
run_options.pop("conversation", None)
run_options.pop("model", None)
extra_body["agent_session_id"] = conversation_id
# Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to
# tell the Responses API which Foundry agent (and version) is in use, since ``model``
@@ -368,7 +367,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
# Strip tools from request body - Foundry API rejects requests with both
# agent endpoint and tools present. FunctionTools are invoked client-side
# by the function invocation layer, not sent to the service.
run_options.pop("model", None)
if not self.allow_preview:
run_options.pop("tools", None)
run_options.pop("tool_choice", None)
@@ -28,8 +28,9 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from agent_framework._evaluation import (
AgentEvalConverter,
@@ -39,6 +40,7 @@ from agent_framework._evaluation import (
EvalItemResult,
EvalResults,
EvalScoreResult,
RubricScore,
)
from agent_framework._feature_stage import ExperimentalFeature, experimental
from openai import AsyncOpenAI
@@ -51,6 +53,54 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# region Generated rubric evaluator references
@experimental(feature_id=ExperimentalFeature.EVALS)
@dataclass(frozen=True)
class GeneratedEvaluatorRef:
"""A reference to a rubric evaluator that already exists in Foundry.
Pass instances of this class to :class:`FoundryEvals` to score items
with a pre-existing rubric evaluator (manually authored or
auto-generated through the Foundry portal). agent-framework is a
consumer here: it does not create or modify the evaluator definition;
it only references the persisted version by name.
Pinning ``version`` is strongly recommended so evaluation runs are
reproducible. ``version=None`` resolves to whichever version is
current at execution time; :class:`FoundryEvals` emits a warning when
a versionless reference is used. CI gates should always pass a
concrete version.
Attributes:
name: Evaluator name as stored in the Foundry project (for
example ``"reservation-policy-rubric"``). Distinct from
built-in evaluators such as ``"builtin.relevance"``.
version: Pinned evaluator version. ``None`` means "latest" —
this is discouraged for CI/repro and :class:`FoundryEvals`
will emit a warning when used.
display_name: Optional human-readable name used in result
summaries. Defaults to ``name`` when unset.
"""
name: str
version: str | None = None
display_name: str | None = None
@classmethod
def latest(cls, name: str, *, display_name: str | None = None) -> GeneratedEvaluatorRef:
"""Construct a versionless reference (resolves to the latest version at run time).
Discouraged for reproducible runs. Prefer the constructor with
an explicit ``version`` so CI and replay evaluations stay stable
when the evaluator is updated in Foundry.
"""
return cls(name=name, version=None, display_name=display_name)
# endregion
# Agent evaluators that accept query/response as conversation arrays.
# Maintained manually — check https://learn.microsoft.com/en-us/azure/ai-studio/how-to/develop/evaluate-sdk
# for the latest evaluator list. These are the evaluators that need conversation-format input.
@@ -166,7 +216,7 @@ def _resolve_evaluator(name: str) -> str:
def _build_testing_criteria(
evaluators: Sequence[str],
evaluators: Sequence[str | GeneratedEvaluatorRef],
model: str,
*,
include_data_mapping: bool = False,
@@ -175,7 +225,9 @@ def _build_testing_criteria(
"""Build ``testing_criteria`` for ``evals.create()``.
Args:
evaluators: Evaluator names.
evaluators: Evaluator names (built-in shorts / fully-qualified
``builtin.*`` names) or :class:`GeneratedEvaluatorRef`
instances for generated rubric evaluators.
model: Model deployment for the LLM judge.
include_data_mapping: Whether to include field-level data mapping
(required for the JSONL data source, not needed for response-based).
@@ -183,7 +235,38 @@ def _build_testing_criteria(
definitions.
"""
criteria: list[dict[str, Any]] = []
for name in evaluators:
for entry_spec in evaluators:
if isinstance(entry_spec, GeneratedEvaluatorRef):
short = entry_spec.display_name or entry_spec.name
ref_entry: dict[str, Any] = {
"type": "azure_ai_evaluator",
"name": short,
"evaluator_name": entry_spec.name,
"initialization_parameters": {"deployment_name": model},
}
if entry_spec.version is not None:
ref_entry["evaluator_version"] = entry_spec.version
else:
logger.warning(
"GeneratedEvaluatorRef '%s' has no pinned version; the eval run "
"will resolve to whichever version is current at execution time. "
"Pin the version for reproducible runs.",
entry_spec.name,
)
if include_data_mapping:
# Rubric evaluators accept conversation arrays like agent
# evaluators, plus tool_definitions when items are tool-aware.
ref_mapping: dict[str, str] = {
"query": "{{item.query_messages}}",
"response": "{{item.response_messages}}",
}
if include_tool_definitions:
ref_mapping["tool_definitions"] = "{{item.tool_definitions}}"
ref_entry["data_mapping"] = ref_mapping
criteria.append(ref_entry)
continue
name = entry_spec
qualified = _resolve_evaluator(name)
short = name if not name.startswith("builtin.") else name.split(".")[-1]
@@ -247,9 +330,9 @@ def _build_item_schema(
def _resolve_default_evaluators(
evaluators: Sequence[str] | None,
evaluators: Sequence[str | GeneratedEvaluatorRef] | None,
items: Sequence[EvalItem | dict[str, Any]] | None = None,
) -> list[str]:
) -> list[str | GeneratedEvaluatorRef]:
"""Resolve evaluators, applying defaults when ``None``.
Defaults to relevance + coherence + task_adherence. Automatically adds
@@ -258,7 +341,7 @@ def _resolve_default_evaluators(
if evaluators is not None:
return list(evaluators)
result = list(_DEFAULT_EVALUATORS)
result: list[str | GeneratedEvaluatorRef] = list(_DEFAULT_EVALUATORS)
if items is not None:
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
if has_tools:
@@ -267,14 +350,24 @@ def _resolve_default_evaluators(
def _filter_tool_evaluators(
evaluators: list[str],
evaluators: list[str | GeneratedEvaluatorRef],
items: Sequence[EvalItem | dict[str, Any]],
) -> list[str]:
"""Remove tool evaluators if no items have tool definitions."""
) -> list[str | GeneratedEvaluatorRef]:
"""Remove tool evaluators if no items have tool definitions.
Generated rubric evaluators are tool-aware but not tool-required; they
are preserved regardless of whether items carry tool definitions.
"""
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
if has_tools:
return evaluators
filtered = [e for e in evaluators if _resolve_evaluator(e) not in _TOOL_EVALUATORS]
def _is_tool_only(spec: str | GeneratedEvaluatorRef) -> bool:
if isinstance(spec, GeneratedEvaluatorRef):
return False
return _resolve_evaluator(spec) in _TOOL_EVALUATORS
filtered = [e for e in evaluators if not _is_tool_only(e)]
if not filtered:
raise ValueError(
f"All requested evaluators {evaluators} require tool definitions, "
@@ -282,7 +375,7 @@ def _filter_tool_evaluators(
"or choose evaluators that do not require tools."
)
if len(filtered) < len(evaluators):
removed = [e for e in evaluators if _resolve_evaluator(e) in _TOOL_EVALUATORS]
removed = [e for e in evaluators if _is_tool_only(e)]
logger.info("Removed tool evaluators %s (no items have tools)", removed)
return filtered
@@ -354,6 +447,114 @@ def _extract_per_evaluator(run: RunRetrieveResponse) -> dict[str, dict[str, int]
return per_eval
_RUBRIC_DIMENSION_KEYS: tuple[str, ...] = ("dimension_scores", "rubric_scores")
"""Property keys that may carry per-dimension rubric breakdowns.
The published Foundry rubric-evaluator output format uses
``properties.dimension_scores`` (see the Microsoft Learn "Rubric
evaluators" reference). Earlier preview builds and some SDK shapes
used ``rubric_scores``; we accept both for defensive forward/backward
compatibility.
"""
def _parse_dimension_entries(raw: Any) -> list[RubricScore]:
"""Parse a raw list-like payload into ``RubricScore`` instances.
Returns an empty list when ``raw`` is falsy, not iterable, or
contains no well-formed entries.
"""
if not raw:
return []
try:
raw_iter: Iterable[Any] = iter(raw)
except TypeError:
return []
parsed: list[RubricScore] = []
for raw_entry in raw_iter:
entry: Any = raw_entry
try:
rid: Any
score_val: Any
applicable: Any
weight: Any
reason: Any
if isinstance(entry, dict):
entry_any = cast("dict[str, Any]", entry)
rid = entry_any.get("id")
score_val = entry_any.get("score")
applicable = entry_any.get("applicable")
weight = entry_any.get("weight")
reason = entry_any.get("reason", "")
else:
rid = getattr(entry, "id", None)
score_val = getattr(entry, "score", None)
applicable = getattr(entry, "applicable", None)
weight = getattr(entry, "weight", None)
reason = getattr(entry, "reason", "") or ""
if rid is None or weight is None or applicable is None:
continue
parsed.append(
RubricScore(
id=str(rid),
score=int(score_val) if isinstance(score_val, (int, float)) else None,
applicable=bool(applicable),
weight=int(weight),
reason=str(reason) if reason is not None else "",
)
)
except (TypeError, ValueError):
logger.debug("Skipping malformed rubric dimension entry: %s", cast("Any", entry), exc_info=True)
return parsed
def _extract_rubric_scores(sample: Any) -> list[RubricScore] | None:
"""Extract typed ``RubricScore`` instances from an evaluator's raw sample payload.
Foundry rubric evaluators include a per-dimension breakdown under
``properties.dimension_scores`` on each result (preview builds used
``rubric_scores``; both keys are accepted, with the canonical
``dimension_scores`` taking priority). The exact location may
vary across SDK versions, so this helper accepts a few shapes:
* The SDK ``sample`` object exposes
``properties.dimension_scores`` / ``properties.rubric_scores``.
* The ``sample`` is a dict containing the same under
``properties.<key>``.
* The ``sample`` is a dict with ``dimension_scores`` /
``rubric_scores`` at the top level.
Returns ``None`` when no rubric scores are present (i.e. the
evaluator was not a rubric evaluator).
"""
if sample is None:
return None
containers: list[Any] = []
properties: Any = getattr(sample, "properties", None)
if properties is not None:
containers.append(properties)
if isinstance(sample, dict):
sample_any = cast("dict[str, Any]", sample)
props_dict: Any = sample_any.get("properties")
if props_dict is not None and props_dict is not properties:
containers.append(props_dict)
containers.append(sample_any)
for container in containers:
for key in _RUBRIC_DIMENSION_KEYS:
raw: Any = None
if isinstance(container, dict):
raw = cast("dict[str, Any]", container).get(key)
elif hasattr(container, key):
raw = getattr(container, key, None)
parsed = _parse_dimension_entries(raw)
if parsed:
return parsed
return None
async def _fetch_output_items(
client: AsyncOpenAI,
eval_id: str,
@@ -377,12 +578,15 @@ async def _fetch_output_items(
# Extract per-evaluator scores
scores: list[EvalScoreResult] = []
for r in oi.results or []:
sample = r.sample
dimensions = _extract_rubric_scores(sample)
scores.append(
EvalScoreResult(
name=r.name,
score=r.score,
passed=r.passed,
sample=r.sample,
sample=sample,
dimensions=dimensions,
)
)
@@ -394,15 +598,18 @@ async def _fetch_output_items(
output_text: str | None = None
response_id: str | None = None
sample = oi.sample
if sample is not None: # pyright: ignore[reportUnnecessaryComparison]
err = sample.error
if err is not None and (err.code or err.message): # pyright: ignore[reportUnnecessaryComparison]
# mypy infers oi.sample as dict[str, object] | None, but the
# OpenAI SDK actually returns a typed Sample model. Cast to Any so
# both type checkers accept the attribute access pattern.
oi_sample: Any = oi.sample
if oi_sample is not None:
err = oi_sample.error
if err is not None and (err.code or err.message):
error_code = err.code or None
error_message = err.message or None
usage = sample.usage
if usage is not None and usage.total_tokens: # pyright: ignore[reportUnnecessaryComparison]
usage = oi_sample.usage
if usage is not None and usage.total_tokens:
token_usage = {
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
@@ -411,13 +618,13 @@ async def _fetch_output_items(
}
# Extract input/output text
if sample.input:
parts = [si.content for si in sample.input if si.role == "user"]
if oi_sample.input:
parts = [si.content for si in oi_sample.input if si.role == "user"]
if parts:
input_text = " ".join(parts)
if sample.output:
parts = [so.content or "" for so in sample.output if so.role == "assistant"]
if oi_sample.output:
parts = [so.content or "" for so in oi_sample.output if so.role == "assistant"]
if parts:
output_text = " ".join(parts)
@@ -472,7 +679,7 @@ async def _evaluate_via_responses_impl(
*,
client: AsyncOpenAI,
response_ids: Sequence[str],
evaluators: list[str],
evaluators: list[str | GeneratedEvaluatorRef],
model: str,
eval_name: str,
poll_interval: float,
@@ -573,8 +780,11 @@ class FoundryEvals:
(from ``azure.ai.projects.aio``). Provide this or *client*.
model: Model deployment name for the evaluator LLM judge.
Resolved from ``client.model`` when omitted.
evaluators: Evaluator names (e.g. ``["relevance", "tool_call_accuracy"]``).
When ``None`` (default), uses smart defaults based on item data.
evaluators: Evaluator specifications. Entries may be built-in
short names (e.g. ``"relevance"``), fully-qualified
``"builtin.*"`` names, or :class:`GeneratedEvaluatorRef`
instances for previously generated rubric evaluators. When
``None`` (default), uses smart defaults based on item data.
conversation_split: How to split multi-turn conversations into
query/response halves. Defaults to ``LAST_TURN``. Pass a
``ConversationSplit`` enum value or a custom callable — see
@@ -623,7 +833,7 @@ class FoundryEvals:
client: FoundryChatClient | None = None,
project_client: AIProjectClient | None = None,
model: str | None = None,
evaluators: Sequence[str] | None = None,
evaluators: Sequence[str | GeneratedEvaluatorRef] | None = None,
conversation_split: ConversationSplitter = ConversationSplit.LAST_TURN,
poll_interval: float = 5.0,
timeout: float = 180.0,
@@ -642,7 +852,9 @@ class FoundryEvals:
"Model is required. Pass model= explicitly or use a FoundryChatClient that has a model configured."
)
self._model = resolved_model
self._evaluators = list(evaluators) if evaluators is not None else None
self._evaluators: list[str | GeneratedEvaluatorRef] | None = (
list(evaluators) if evaluators is not None else None
)
self._conversation_split = conversation_split
self._poll_interval = poll_interval
self._timeout = timeout
@@ -678,7 +890,7 @@ class FoundryEvals:
async def _evaluate_via_dataset(
self,
items: Sequence[EvalItem],
evaluators: list[str],
evaluators: list[str | GeneratedEvaluatorRef],
eval_name: str,
) -> EvalResults:
"""Evaluate using JSONL dataset upload path."""
@@ -203,7 +203,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
"""Test that _prepare_options strips model and tool-loop fields from run_options."""
"""Test that _prepare_options strips tool-loop fields but preserves model for non-session requests."""
mock_project = MagicMock()
mock_openai = MagicMock()
@@ -235,16 +235,49 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_
options={"tools": [my_func]},
)
assert "model" not in result
# model is preserved for non-session (PromptAgent) requests
assert result["model"] == "gpt-4.1"
assert "tools" not in result
assert "tool_choice" not in result
assert "parallel_tool_calls" not in result
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
assert result == {
"model": "gpt-4.1",
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
}
async def test_raw_foundry_agent_chat_client_prepare_options_strips_model_for_hosted_session() -> None:
"""Test that model is stripped when using a hosted agent session (not a PromptAgent)."""
mock_project = MagicMock()
mock_openai = MagicMock()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={
"model": "gpt-4.1",
"previous_response_id": "resp_abc",
},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={"conversation_id": "agent-session-123"},
)
assert "model" not in result
assert "previous_response_id" not in result
assert result["extra_body"]["agent_session_id"] == "agent-session-123"
assert result["extra_body"]["agent_reference"] == {"name": "test-agent", "type": "agent_reference"}
async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None:
"""First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body.
@@ -272,7 +305,6 @@ async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_refer
options={},
)
assert "model" not in result
assert result["extra_body"] == {
"agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"},
}
@@ -333,7 +365,8 @@ async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_referen
options={},
)
assert "model" not in result
# model is preserved for non-session requests (platform tolerates it for hosted agents)
assert result["model"] == "gpt-4.1"
# No extra_body at all is the cleanest signal — agent_reference must not be injected here.
assert "extra_body" not in result
@@ -363,6 +396,39 @@ async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_age
assert result["extra_body"]["agent_reference"] == caller_reference
async def test_raw_foundry_agent_chat_client_prepare_options_preserves_model_for_resp_continuation() -> None:
"""Test that model is preserved when conversation_id is a resp_* continuation (HostedAgent v1 / v2-no-session)."""
mock_project = MagicMock()
mock_openai = MagicMock()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={
"model": "gpt-4.1",
"previous_response_id": "resp_abc123",
},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={"conversation_id": "resp_abc123"},
)
# model preserved — resp_* is standard Responses API continuity, not a hosted session
assert result["model"] == "gpt-4.1"
# previous_response_id preserved — not stripped outside hosted session path
assert result["previous_response_id"] == "resp_abc123"
# no agent_session_id injected
assert "extra_body" not in result or "agent_session_id" not in result.get("extra_body", {})
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
"""Test that service_session_id is forwarded as agent_session_id for hosted sessions."""
@@ -25,16 +25,25 @@ from agent_framework._evaluation import (
from agent_framework._workflows._workflow import WorkflowRunResult
from openai import AsyncOpenAI
from agent_framework_foundry import GeneratedEvaluatorRef
from agent_framework_foundry._foundry_evals import (
_AGENT_EVALUATORS,
_BUILTIN_EVALUATORS,
_TOOL_EVALUATORS,
FoundryEvals,
_build_item_schema,
_build_testing_criteria,
_extract_per_evaluator,
_extract_result_counts,
_extract_rubric_scores,
_fetch_output_items,
_filter_tool_evaluators,
_poll_eval_run,
_resolve_default_evaluators,
_resolve_evaluator,
_resolve_openai_client,
evaluate_foundry_target,
evaluate_traces,
)
@@ -806,6 +815,67 @@ class TestBuildTestingCriteria:
for c in criteria:
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
def test_generated_evaluator_ref_pinned_version(self) -> None:
ref = GeneratedEvaluatorRef(name="my-rubric", version="1")
criteria = _build_testing_criteria([ref], "gpt-4o", include_data_mapping=True)
assert len(criteria) == 1
c = criteria[0]
assert c["type"] == "azure_ai_evaluator"
assert c["evaluator_name"] == "my-rubric"
assert c["evaluator_version"] == "1"
assert c["name"] == "my-rubric"
assert c["initialization_parameters"] == {"deployment_name": "gpt-4o"}
assert c["data_mapping"] == {
"query": "{{item.query_messages}}",
"response": "{{item.response_messages}}",
}
def test_generated_evaluator_ref_display_name_used_as_short(self) -> None:
ref = GeneratedEvaluatorRef(name="my-rubric", version="2", display_name="My Rubric")
criteria = _build_testing_criteria([ref], "gpt-4o")
assert criteria[0]["name"] == "My Rubric"
assert criteria[0]["evaluator_name"] == "my-rubric"
def test_generated_evaluator_ref_tool_definitions_added(self) -> None:
ref = GeneratedEvaluatorRef(name="my-rubric", version="1")
criteria = _build_testing_criteria(
[ref],
"gpt-4o",
include_data_mapping=True,
include_tool_definitions=True,
)
assert criteria[0]["data_mapping"]["tool_definitions"] == "{{item.tool_definitions}}"
def test_generated_evaluator_ref_unpinned_warns(self, caplog: pytest.LogCaptureFixture) -> None:
import logging
ref = GeneratedEvaluatorRef.latest("my-rubric")
with caplog.at_level(logging.WARNING, logger="agent_framework_foundry._foundry_evals"):
criteria = _build_testing_criteria([ref], "gpt-4o")
assert "evaluator_version" not in criteria[0]
assert any("no pinned version" in r.message for r in caplog.records)
def test_generated_evaluator_ref_mixed_with_builtins(self) -> None:
ref = GeneratedEvaluatorRef(name="my-rubric", version="1")
criteria = _build_testing_criteria(
["relevance", ref, "task_adherence"],
"gpt-4o",
include_data_mapping=True,
)
assert [c["name"] for c in criteria] == ["relevance", "my-rubric", "task_adherence"]
assert criteria[0]["evaluator_name"] == "builtin.relevance"
assert criteria[1]["evaluator_name"] == "my-rubric"
assert criteria[2]["evaluator_name"] == "builtin.task_adherence"
# ---------------------------------------------------------------------------
# _build_item_schema
@@ -1263,6 +1333,29 @@ class TestFilterToolEvaluators:
items,
)
def test_preserves_generated_ref_when_no_tools(self) -> None:
ref = GeneratedEvaluatorRef(name="rubric", version="1")
items = [
EvalItem(conversation=[Message("user", ["q"]), Message("assistant", ["r"])]),
]
result = _filter_tool_evaluators(
["relevance", ref, "tool_call_accuracy"],
items,
)
assert "relevance" in result
assert ref in result
assert "tool_call_accuracy" not in result
def test_generated_ref_alone_does_not_raise(self) -> None:
ref = GeneratedEvaluatorRef(name="rubric", version="1")
items = [
EvalItem(conversation=[Message("user", ["q"]), Message("assistant", ["r"])]),
]
result = _filter_tool_evaluators([ref], items)
assert result == [ref]
# ---------------------------------------------------------------------------
# EvalResults
@@ -2267,7 +2360,6 @@ class TestEvalResultsWithItems:
class TestFetchOutputItems:
async def test_fetches_and_converts_output_items(self) -> None:
from agent_framework_foundry._foundry_evals import _fetch_output_items
# Build mock output items matching the OpenAI SDK schema
mock_result = MagicMock()
@@ -2329,7 +2421,6 @@ class TestFetchOutputItems:
assert item.error_code is None
async def test_handles_errored_item(self) -> None:
from agent_framework_foundry._foundry_evals import _fetch_output_items
mock_error = MagicMock()
mock_error.code = "QueryExtractionError"
@@ -2361,7 +2452,6 @@ class TestFetchOutputItems:
assert len(item.scores) == 0
async def test_handles_api_failure_gracefully(self) -> None:
from agent_framework_foundry._foundry_evals import _fetch_output_items
mock_client = MagicMock()
mock_client.evals.runs.output_items.list = AsyncMock(side_effect=TypeError("API error"))
@@ -2369,6 +2459,166 @@ class TestFetchOutputItems:
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert items == []
async def test_extracts_rubric_scores_from_dict_sample(self) -> None:
mock_result = MagicMock()
mock_result.name = "my-rubric"
mock_result.score = 0.85
mock_result.passed = True
mock_result.sample = {
"properties": {
"rubric_scores": [
{"id": "policy", "score": 4, "applicable": True, "weight": 1, "reason": "ok"},
{"id": "safety", "score": None, "applicable": False, "weight": 1, "reason": "n/a"},
]
}
}
mock_oi = MagicMock()
mock_oi.id = "oi_1"
mock_oi.status = "pass"
mock_oi.results = [mock_result]
mock_oi.sample = None
mock_oi.datasource_item = {}
mock_client = MagicMock()
mock_client.evals.runs.output_items.list = AsyncMock(return_value=_AsyncPage([mock_oi]))
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert len(items) == 1
scores = items[0].scores
assert len(scores) == 1
assert scores[0].dimensions is not None
assert len(scores[0].dimensions) == 2
policy = next(d for d in scores[0].dimensions if d.id == "policy")
assert policy.score == 4
assert policy.applicable is True
assert policy.weight == 1
assert policy.reason == "ok"
safety = next(d for d in scores[0].dimensions if d.id == "safety")
assert safety.score is None
assert safety.applicable is False
async def test_no_rubric_scores_when_absent(self) -> None:
mock_result = MagicMock()
mock_result.name = "relevance"
mock_result.score = 0.85
mock_result.passed = True
mock_result.sample = None
mock_oi = MagicMock()
mock_oi.id = "oi_2"
mock_oi.status = "pass"
mock_oi.results = [mock_result]
mock_oi.sample = None
mock_oi.datasource_item = {}
mock_client = MagicMock()
mock_client.evals.runs.output_items.list = AsyncMock(return_value=_AsyncPage([mock_oi]))
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert items[0].scores[0].dimensions is None
class TestExtractRubricScores:
def test_handles_attribute_style_properties(self) -> None:
rs = MagicMock()
rs.id = "policy"
rs.score = 5
rs.applicable = True
rs.weight = 2
rs.reason = "ok"
sample = MagicMock()
sample.properties = MagicMock()
sample.properties.rubric_scores = [rs]
result = _extract_rubric_scores(sample)
assert result is not None
assert result[0].id == "policy"
assert result[0].score == 5
assert result[0].weight == 2
def test_top_level_rubric_scores_in_dict(self) -> None:
sample = {"rubric_scores": [{"id": "a", "score": 3, "applicable": True, "weight": 1, "reason": "r"}]}
result = _extract_rubric_scores(sample)
assert result is not None
assert result[0].id == "a"
def test_returns_none_when_missing(self) -> None:
assert _extract_rubric_scores(None) is None
assert _extract_rubric_scores({}) is None
assert _extract_rubric_scores({"properties": {}}) is None
def test_skips_malformed_entries(self) -> None:
sample = {
"properties": {
"rubric_scores": [
{"id": "good", "score": 3, "applicable": True, "weight": 1, "reason": "ok"},
{"id": "bad-no-weight", "score": 2, "applicable": True, "reason": "x"},
]
}
}
result = _extract_rubric_scores(sample)
assert result is not None
assert len(result) == 1
assert result[0].id == "good"
def test_canonical_dimension_scores_key_from_docs(self) -> None:
"""Per the Microsoft Learn docs, runtime output uses ``properties.dimension_scores``."""
sample = {
"properties": {
"dimension_scores": [
{
"id": "intent_recognition",
"score": 5,
"applicable": True,
"weight": 9,
"reason": "Identified correctly.",
},
{
"id": "general_quality",
"score": 4,
"applicable": True,
"weight": 5,
"reason": "Strong overall.",
},
]
}
}
result = _extract_rubric_scores(sample)
assert result is not None
assert [r.id for r in result] == ["intent_recognition", "general_quality"]
assert [r.score for r in result] == [5, 4]
assert [r.weight for r in result] == [9, 5]
def test_dimension_scores_via_attribute(self) -> None:
"""Canonical key also resolves when properties exposes ``dimension_scores`` as an attr."""
rs = MagicMock()
rs.id = "policy_enforcement"
rs.score = 1
rs.applicable = True
rs.weight = 5
rs.reason = "violated"
sample = MagicMock()
sample.properties = MagicMock(spec=["dimension_scores"])
sample.properties.dimension_scores = [rs]
result = _extract_rubric_scores(sample)
assert result is not None
assert result[0].id == "policy_enforcement"
assert result[0].score == 1
# ---------------------------------------------------------------------------
# _poll_eval_run — timeout / failed / canceled paths
@@ -2378,7 +2628,6 @@ class TestFetchOutputItems:
class TestPollEvalRun:
async def test_timeout_returns_timeout_status(self) -> None:
"""Poll timeout returns EvalResults with status='timeout'."""
from agent_framework_foundry._foundry_evals import _poll_eval_run
mock_client = MagicMock()
mock_pending = MagicMock()
@@ -2392,7 +2641,6 @@ class TestPollEvalRun:
async def test_failed_run_returns_error(self) -> None:
"""Failed run returns EvalResults with error message."""
from agent_framework_foundry._foundry_evals import _poll_eval_run
mock_client = MagicMock()
mock_failed = MagicMock()
@@ -2410,7 +2658,6 @@ class TestPollEvalRun:
async def test_canceled_run_returns_canceled_status(self) -> None:
"""Canceled run returns EvalResults with status='canceled'."""
from agent_framework_foundry._foundry_evals import _poll_eval_run
mock_client = MagicMock()
mock_canceled = MagicMock()
@@ -2435,7 +2682,6 @@ class TestPollEvalRun:
class TestEvaluateTraces:
async def test_raises_without_required_args(self) -> None:
"""Raises ValueError when no response_ids, trace_ids, or agent_id given."""
from agent_framework_foundry._foundry_evals import evaluate_traces
mock_client = MagicMock()
with pytest.raises(ValueError, match="Provide at least one of"):
@@ -2446,7 +2692,6 @@ class TestEvaluateTraces:
async def test_response_ids_path(self) -> None:
"""evaluate_traces with response_ids uses the responses API path."""
from agent_framework_foundry._foundry_evals import evaluate_traces
mock_client = MagicMock()
@@ -2494,7 +2739,6 @@ class TestEvaluateTraces:
async def test_trace_ids_path(self) -> None:
"""evaluate_traces with trace_ids builds azure_ai_traces data source."""
from agent_framework_foundry._foundry_evals import evaluate_traces
mock_client = MagicMock()
@@ -2534,7 +2778,6 @@ class TestEvaluateTraces:
class TestEvaluateFoundryTarget:
async def test_happy_path(self) -> None:
"""evaluate_foundry_target creates eval + run and polls to completion."""
from agent_framework_foundry._foundry_evals import evaluate_foundry_target
mock_client = MagicMock()
@@ -2670,13 +2913,11 @@ class TestEvaluatorSetConsistency:
"""Verify that _AGENT_EVALUATORS and _TOOL_EVALUATORS are subsets of _BUILTIN_EVALUATORS."""
def test_agent_evaluators_subset(self):
from agent_framework_foundry._foundry_evals import _AGENT_EVALUATORS, _BUILTIN_EVALUATORS
diff = _AGENT_EVALUATORS - set(_BUILTIN_EVALUATORS.values())
assert not diff, f"_AGENT_EVALUATORS has names not in _BUILTIN_EVALUATORS: {diff}"
def test_tool_evaluators_subset(self):
from agent_framework_foundry._foundry_evals import _BUILTIN_EVALUATORS, _TOOL_EVALUATORS
diff = _TOOL_EVALUATORS - set(_BUILTIN_EVALUATORS.values())
assert not diff, f"_TOOL_EVALUATORS has names not in _BUILTIN_EVALUATORS: {diff}"
@@ -2690,7 +2931,6 @@ class TestEvaluatorSetConsistency:
class TestEvaluateTracesAgentId:
async def test_agent_id_only_path(self) -> None:
"""evaluate_traces with agent_id only builds azure_ai_traces data source."""
from agent_framework_foundry._foundry_evals import evaluate_traces
mock_client = MagicMock()
@@ -2748,7 +2988,6 @@ class TestFilterToolEvaluatorsRaises:
class TestEvaluateFoundryTargetValidation:
async def test_target_without_type_raises(self) -> None:
"""target dict without 'type' key raises ValueError."""
from agent_framework_foundry._foundry_evals import evaluate_foundry_target
mock_client = MagicMock()
with pytest.raises(ValueError, match="'type' key"):
@@ -9,7 +9,7 @@ import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Sequence
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from dataclasses import asdict, is_dataclass
from pathlib import Path
@@ -472,14 +472,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# 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
async for item in _to_outputs_for_messages(
response_event_stream,
response.messages,
approval_storage=self._approval_storage,
):
yield item
yield response_event_stream.emit_completed()
else:
if tracker is None: # pragma: no cover - defensive, set above
@@ -620,10 +618,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
checkpoint_storage=write_storage,
)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
async for item in _to_outputs_for_messages(response_event_stream, response.messages):
yield item
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
@@ -729,7 +725,7 @@ class _OutputItemTracker:
yield self._fc_builder.emit_arguments_delta(args_str)
elif content.type == "mcp_server_tool_call" and content.tool_name:
key = f"{content.server_name or 'default'}::{content.tool_name}"
key = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}"
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
yield from self._close()
yield from self._open_mcp_call(content)
@@ -738,6 +734,24 @@ class _OutputItemTracker:
if self._mcp_builder is not None:
yield self._mcp_builder.emit_arguments_delta(args_str)
elif (
content.type == "mcp_server_tool_result"
and self._active_type == "mcp_server_tool_call"
and self._mcp_builder is not None
and content.call_id is not None
and content.call_id == self._mcp_builder.item_id
):
accumulated = "".join(self._accumulated)
yield self._mcp_builder.emit_arguments_done(accumulated)
yield self._mcp_builder.emit_completed()
yield self._mcp_builder.emit_done(output=_stringify_mcp_output(content.output))
self._mcp_builder = None
self._active_type = None
self._active_id = None
self._accumulated.clear()
self.needs_async = False
return
else:
yield from self._close()
self.needs_async = True
@@ -777,9 +791,10 @@ class _OutputItemTracker:
self._mcp_builder = self._stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
item_id=content.call_id,
)
self._active_type = "mcp_server_tool_call"
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
self._active_id = content.call_id or f"{content.server_name or 'default'}::{content.tool_name}"
yield self._mcp_builder.emit_added()
def _close(self) -> Generator[ResponseStreamEvent]:
@@ -927,16 +942,19 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No
if item.type == "mcp_call":
mcp = cast(ItemMcpToolCall, item)
contents = [
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
]
if getattr(mcp, "output", None) is not None:
contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output))
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
contents=contents,
)
if item.type == "mcp_approval_request":
@@ -1197,16 +1215,19 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva
if item.type == "mcp_call":
mcp = cast(OutputItemMcpToolCall, item)
contents = [
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
]
if getattr(mcp, "output", None) is not None:
contents.append(Content.from_mcp_server_tool_result(call_id=mcp.id, output=mcp.output))
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
contents=contents,
)
if item.type == "mcp_approval_request":
@@ -1583,6 +1604,7 @@ async def _to_outputs(
mcp_call = stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
item_id=content.call_id,
)
yield mcp_call.emit_added()
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
@@ -1657,4 +1679,91 @@ async def _to_outputs(
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
def _stringify_mcp_output(output: Any) -> str:
"""Convert hosted MCP output payloads into the string shape expected by mcp_call.output."""
if output is None:
return ""
if isinstance(output, str):
return output
if isinstance(output, Mapping):
text = cast(Any, output).get("text")
if isinstance(text, str):
return text
return json.dumps(output, default=str)
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
parts: list[str] = []
entries = cast(Sequence[object], output)
for entry in entries:
if isinstance(entry, Content) and entry.type == "text":
parts.append(entry.text or "")
continue
parts.append(_stringify_mcp_output(entry))
return "".join(parts)
return str(output)
def _emit_completed_mcp_call(
stream: ResponseEventStream,
call_content: Content,
*,
arguments: str,
output: str,
) -> Generator[ResponseStreamEvent]:
"""Emit a single completed MCP call item carrying both arguments and output."""
mcp_call = stream.add_output_item_mcp_call(
server_label=call_content.server_name or "default",
name=call_content.tool_name or "",
item_id=call_content.call_id,
)
yield mcp_call.emit_added()
yield mcp_call.emit_arguments_done(arguments)
yield mcp_call.emit_completed()
yield mcp_call.emit_done(output=output)
async def _to_outputs_for_messages(
stream: ResponseEventStream,
messages: Sequence[Message],
*,
approval_storage: ApprovalStorage | None = None,
) -> AsyncIterator[ResponseStreamEvent]:
"""Convert messages to output events with hosted-MCP call/result coalescing.
Parse once in message/content order and emit either:
- a single canonical completed ``mcp_call`` when adjacent hosted MCP
call/result content are encountered, or
- standard output items for all other content types.
"""
pending_mcp_call: Content | None = None
for message in messages:
for content in message.contents:
if pending_mcp_call is not None:
if content.type == "mcp_server_tool_result" and content.call_id == pending_mcp_call.call_id:
for event in _emit_completed_mcp_call(
stream,
pending_mcp_call,
arguments=_arguments_to_str(pending_mcp_call.arguments),
output=_stringify_mcp_output(content.output),
):
yield event
pending_mcp_call = None
continue
async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage):
yield event
pending_mcp_call = None
if content.type == "mcp_server_tool_call" and content.call_id:
pending_mcp_call = content
continue
async for event in _to_outputs(stream, content, approval_storage=approval_storage):
yield event
if pending_mcp_call is not None:
async for event in _to_outputs(stream, pending_mcp_call, approval_storage=approval_storage):
yield event
# endregion
@@ -25,7 +25,7 @@ classifiers = [
dependencies = [
"agent-framework-core>=1.7.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b5,<2",
"azure-ai-agentserver-responses>=1.0.0b7,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
]
@@ -260,6 +260,50 @@ class TestNonStreaming:
assert "function_call_output" in types
assert "message" in types
async def test_hosted_mcp_call_and_result_persist_as_single_mcp_call(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
)
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "mcp_call" in types
assert "custom_tool_call_output" not in types
mcp_items = [item for item in body["output"] if item["type"] == "mcp_call"]
assert len(mcp_items) == 1
assert mcp_items[0]["id"] == "mcp_abc123"
assert mcp_items[0]["output"] == "found 10 cats"
async def test_reasoning_content(self) -> None:
agent = _make_agent(
response=AgentResponse(
@@ -617,6 +661,53 @@ class TestStreaming:
assert "response.output_item.added" in types
assert "response.output_item.done" in types
async def test_mcp_tool_call_and_result_streaming_emit_single_completed_mcp_call(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q":',
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments=' "cats"}',
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
role="tool",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
done_events = [e for e in events if e["event"] == "response.output_item.done"]
assert len(done_events) == 1
assert done_events[0]["data"]["item"]["type"] == "mcp_call"
assert done_events[0]["data"]["item"]["id"] == "mcp_abc123"
assert done_events[0]["data"]["item"]["output"] == "found 10 cats"
# endregion
@@ -720,6 +811,24 @@ class TestOutputItemToMessage:
assert msg.contents[0].server_name == "my_server"
assert msg.contents[0].tool_name == "search"
async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpToolCall
item = OutputItemMcpToolCall({
"type": "mcp_call",
"id": "mcp-1",
"server_label": "my_server",
"name": "search",
"arguments": '{"q": "test"}',
"output": "found 10 cats",
})
msg = await _output_item_to_message(item)
assert msg.role == "assistant"
assert len(msg.contents) == 2
assert msg.contents[0].type == "mcp_server_tool_call"
assert msg.contents[1].type == "mcp_server_tool_result"
assert msg.contents[1].output == "found 10 cats"
async def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
@@ -1189,6 +1298,25 @@ class TestItemToMessage:
assert msg.contents[0].server_name == "my_server"
assert msg.contents[0].tool_name == "search"
async def test_mcp_call_with_output_reconstructs_mcp_result_content(self) -> None:
from azure.ai.agentserver.responses.models import ItemMcpToolCall
item = ItemMcpToolCall({
"type": "mcp_call",
"id": "mcp-1",
"server_label": "my_server",
"name": "search",
"arguments": '{"q": "test"}',
"output": "found 10 cats",
})
msg = await _item_to_message(item)
assert msg is not None
assert msg.role == "assistant"
assert len(msg.contents) == 2
assert msg.contents[0].type == "mcp_server_tool_call"
assert msg.contents[1].type == "mcp_server_tool_result"
assert msg.contents[1].output == "found 10 cats"
async def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest
@@ -1937,6 +2065,75 @@ class TestMultiTurnMixedContent:
assert len(fc_contents) >= 1
assert fc_contents[0].name == "search"
async def test_hosted_mcp_call_round_trip_does_not_orphan_function_call_output(self) -> None:
"""Turn 1 produces hosted MCP call + result, turn 2 must replay both without orphaning output."""
agent = _make_multi_response_agent([
AgentResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
)
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]),
]
),
AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Here are more details")])]),
])
server = _make_server(agent)
resp1 = await _post(server, input_text="Search for cats", stream=False)
assert resp1.status_code == 200
response_id = resp1.json()["id"]
types1 = [item["type"] for item in resp1.json()["output"]]
assert "mcp_call" in types1
assert "custom_tool_call_output" not in types1
resp2 = await _post_json(
server,
{
"model": "test-model",
"input": "Tell me more",
"stream": False,
"previous_response_id": response_id,
},
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "completed"
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
mcp_call_contents = [
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"
]
mcp_result_contents = [
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result"
]
function_result_contents = [
c for m in second_call_messages for c in m.contents if c.type == "function_result"
]
assert len(mcp_call_contents) >= 1
assert len(mcp_result_contents) >= 1
assert all((c.call_id or "") != "mcp_abc123" for c in function_result_contents)
assert any((c.call_id or "") == "mcp_abc123" for c in mcp_call_contents)
assert any((c.call_id or "") == "mcp_abc123" for c in mcp_result_contents)
async def test_multi_turn_reasoning_in_history(self) -> None:
"""Turn 1 produces reasoning + text, turn 2 sees them in history."""
agent = _make_multi_response_agent([
+4 -4
View File
@@ -57,19 +57,19 @@ math = [
[dependency-groups]
dev = [
"uv==0.11.6",
"ruff==0.15.8",
"uv==0.11.17",
"ruff==0.15.15",
"pytest==9.0.3",
"mypy==1.20.0",
"pyright==1.1.408",
#tasks
"poethepoet==0.42.1",
"poethepoet==0.46.0",
"rich>=13.7.1,<15.0.0",
"tomli==2.4.1",
"tomli-w==1.2.0",
# tau2 from source (not available on PyPI)
"tau2@ git+https://github.com/sierra-research/tau2-bench@5ba9e3e56db57c5e4114bf7f901291f09b2c5619",
"prek==0.3.9",
"prek==0.4.3",
]
[project.scripts]
+7 -7
View File
@@ -28,25 +28,25 @@ dependencies = [
[dependency-groups]
dev = [
"uv==0.11.6",
"uv==0.11.17",
"flit==3.12.0",
"ruff==0.15.8",
"ruff==0.15.15",
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-asyncio==1.4.0",
"pytest-cov==7.1.0",
"pytest-xdist[psutil]==3.8.0",
"pytest-timeout==2.4.0",
"pytest-retry==1.7.0",
"mypy==1.20.0",
"pyright==1.1.408",
"mcp[ws]==1.27.0",
"mcp[ws]==1.27.2",
"opentelemetry-sdk==1.40.0",
"azure-monitor-opentelemetry==1.8.7",
"azure-monitor-opentelemetry==1.8.8",
#tasks
"poethepoet==0.42.1",
"poethepoet==0.46.0",
"rich>=13.7.1,<16.0.0",
"tomli==2.4.1",
"prek==0.3.9",
"prek==0.4.3",
]
[tool.uv]
@@ -109,7 +109,10 @@ async def main() -> None:
print(f"\n [calling tool: {content.name}]", flush=True)
print(" ", end="", flush=True)
# Show web search activity when the result arrives with action details.
elif content.type in ("search_tool_call", "search_tool_result") and getattr(content, "tool_name", None) == "web_search":
elif (
content.type in ("search_tool_call", "search_tool_result")
and getattr(content, "tool_name", None) == "web_search"
):
action = None
if content.type == "search_tool_result" and isinstance(content.result, dict):
action = content.result.get("action", {})
@@ -64,7 +64,6 @@ actions:
### Agent Invocation
- `InvokeAzureAgent` - Call an Azure AI agent
- `InvokePromptAgent` - Call a local prompt agent
### Tool Invocation
- `InvokeFunctionTool` - Call a registered Python function
@@ -1,3 +1,12 @@
FOUNDRY_PROJECT_ENDPOINT="<your-project-endpoint>"
FOUNDRY_MODEL="<your-model-deployment>"
# Only needed for evaluate_with_rubric_sample.py — connects to the
# pre-existing Foundry agent that the rubric evaluator was created against.
FOUNDRY_AGENT_NAME="<your-agent-name>"
FOUNDRY_AGENT_VERSION="<your-agent-version>"
# Only needed for evaluate_with_rubric_sample.py — references a rubric
# evaluator you created in Foundry. Pin the version for reproducible runs.
FOUNDRY_RUBRIC_NAME="<your-rubric-name>"
FOUNDRY_RUBRIC_VERSION="<your-rubric-version>"
@@ -35,6 +35,34 @@ Evaluate what already happened — zero changes to agent code:
uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py
```
### Referencing a rubric evaluator created in Foundry
Foundry users can create rubric evaluators in the Foundry portal (or
through the dedicated SDK / REST surface). Once an evaluator exists,
agent-framework consumes it like any other evaluator: pass a
`GeneratedEvaluatorRef(name=..., version=...)` in the `evaluators=`
list and pin the version for reproducible runs.
```python
from agent_framework.foundry import FoundryEvals, GeneratedEvaluatorRef
evals = FoundryEvals(
evaluators=[
GeneratedEvaluatorRef(name="reservation-policy-rubric", version="3"),
"relevance",
"coherence",
],
)
```
Quality gates on rubric output use the standard `EvalResults` helpers,
including `assert_dimension_score_at_least(...)` for per-dimension
thresholds.
See [`evaluate_with_rubric_sample.py`](./evaluate_with_rubric_sample.py)
for a runnable end-to-end example that combines a rubric evaluator with
built-in evaluators and gates a per-dimension threshold.
## Setup
Create a `.env` file with configuration as in the `.env.example` file in this folder.
@@ -44,3 +72,4 @@ Create a `.env` file with configuration as in the `.env.example` file in this fo
- **"I want to test my agent during development"** → `evaluate_agent_sample.py`, Pattern 1
- **"I want to evaluate past agent runs"** → `evaluate_traces_sample.py`
- **"I want to inspect/modify eval data before submitting"** → `evaluate_agent_sample.py`, Pattern 2
- **"I want to score against a custom rubric I created in Foundry"** → `evaluate_with_rubric_sample.py`
@@ -0,0 +1,138 @@
# Copyright (c) Microsoft. All rights reserved.
"""Evaluate a Foundry agent against a rubric evaluator that was created in Foundry.
Rubric evaluators are LLM-as-judge evaluators with custom scoring dimensions
that you define for your domain. agent-framework consumes pre-existing rubric
evaluators — they are authored in the Foundry portal (or via the dedicated
SDK / REST surface) and referenced here by name and version.
See: https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-evaluators/rubric-evaluators
This sample demonstrates:
1. Connecting to a pre-existing Foundry agent (PromptAgent or HostedAgent).
2. Referencing a pre-existing rubric evaluator by ``name`` and ``version``.
3. Mixing the rubric with built-in Foundry evaluators in one run.
4. Asserting per-dimension thresholds with
``EvalResults.assert_dimension_score_at_least(...)`` for CI quality gates.
Starting condition / prerequisites:
- An Azure AI Foundry project with a deployed model.
- A registered Foundry agent (PromptAgent or HostedAgent) in that project.
This is the agent the rubric is meant to evaluate.
- A rubric evaluator already created in the Foundry portal against that
agent. Creating rubrics through the portal currently requires picking a
Foundry agent as the generation context, so this prerequisite is implied
by having a rubric at all.
- Set the following in .env (see ``.env.example``):
- ``FOUNDRY_PROJECT_ENDPOINT``
- ``FOUNDRY_AGENT_NAME`` and ``FOUNDRY_AGENT_VERSION`` for the agent
- ``FOUNDRY_RUBRIC_NAME`` and ``FOUNDRY_RUBRIC_VERSION`` for the rubric
- ``FOUNDRY_MODEL`` for the rubric judge model
"""
import asyncio
import os
from agent_framework import EvalNotPassedError, evaluate_agent
from agent_framework.foundry import FoundryAgent, FoundryChatClient, FoundryEvals, GeneratedEvaluatorRef
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv(override=True)
async def main() -> None:
# 1. Connect to the existing Foundry agent that the rubric was created
# against. PromptAgents and HostedAgents are both supported.
credential = AzureCliCredential()
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
agent = FoundryAgent(
project_endpoint=project_endpoint,
agent_name=os.environ["FOUNDRY_AGENT_NAME"],
agent_version=os.environ.get("FOUNDRY_AGENT_VERSION"),
credential=credential,
)
# 2. Reference the pre-existing rubric evaluator by name + version.
# Always pin a version for reproducible CI runs; versionless refs
# resolve to "latest" and emit a warning at evaluation time.
rubric_name = os.environ["FOUNDRY_RUBRIC_NAME"]
rubric_version = os.environ["FOUNDRY_RUBRIC_VERSION"]
rubric = GeneratedEvaluatorRef(name=rubric_name, version=rubric_version)
# 3. Mix the rubric with built-in evaluators in a single FoundryEvals
# config. FoundryEvals talks to Foundry over the project endpoint, so
# we hand it a FoundryChatClient configured with the same credential.
eval_client = FoundryChatClient(
project_endpoint=project_endpoint,
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
evals = FoundryEvals(
client=eval_client,
evaluators=[
rubric,
FoundryEvals.RELEVANCE,
FoundryEvals.COHERENCE,
],
)
# =========================================================================
# Run evaluation
# =========================================================================
print("=" * 60)
print(f"Evaluating '{agent.name}' with rubric '{rubric_name}' (version {rubric_version})")
print("=" * 60)
results = await evaluate_agent(
agent=agent,
queries=[
"What's the weather like in Seattle?",
"Should I bring an umbrella to London tomorrow?",
],
evaluators=evals,
)
for r in results:
print(f"Status: {r.status}")
print(f"Results: {r.passed}/{r.total} passed")
print(f"Portal: {r.report_url}")
if r.all_passed:
print("[PASS] All passed")
else:
print(f"[FAIL] {r.failed} failed")
# =========================================================================
# Per-dimension quality gate
# =========================================================================
# Rubric evaluators emit per-dimension scores (1–5) on top of the overall
# weighted score. Use assert_dimension_score_at_least to gate CI on a
# specific dimension — e.g., never ship if a critical dimension drops
# below 3.
#
# The dimension_id must match an id defined on your rubric in Foundry.
# ``general_quality`` is used here because it's the conventional
# ``always_applicable: true`` dimension in the Foundry docs' example
# rubric — swap it for whatever dimension id(s) your rubric actually
# defines.
print()
print("=" * 60)
print("Per-dimension quality gate")
print("=" * 60)
for r in results:
try:
r.assert_dimension_score_at_least(
"general_quality",
min_score=3.0,
evaluator=rubric_name,
)
print(f"[PASS] {r.provider}: general_quality >= 3 on every item")
except EvalNotPassedError as exc:
print(f"[FAIL] {r.provider}: dimension gate tripped: {exc}")
if __name__ == "__main__":
asyncio.run(main())
@@ -74,19 +74,22 @@ async def run_agent_framework() -> None:
client = OpenAIChatClient(model="gpt-4.1-mini")
# Create specialized agents
python_expert = Agent(client=client,
python_expert = Agent(
client=client,
name="python_expert",
instructions="You are a Python programming expert. Answer Python-related questions.",
description="Expert in Python programming",
)
javascript_expert = Agent(client=client,
javascript_expert = Agent(
client=client,
name="javascript_expert",
instructions="You are a JavaScript programming expert. Answer JavaScript-related questions.",
description="Expert in JavaScript programming",
)
database_expert = Agent(client=client,
database_expert = Agent(
client=client,
name="database_expert",
instructions="You are a database expert. Answer SQL and database-related questions.",
description="Expert in databases and SQL",
@@ -95,7 +98,8 @@ async def run_agent_framework() -> None:
workflow = GroupChatBuilder(
participants=[python_expert, javascript_expert, database_expert],
max_rounds=1,
orchestrator_agent=Agent(client=client,
orchestrator_agent=Agent(
client=client,
name="selector_manager",
instructions="Based on the conversation, select the most appropriate expert to respond next.",
),
@@ -113,7 +113,8 @@ async def run_agent_framework() -> None:
client = OpenAIChatClient(model="gpt-4.1-mini")
# Create triage agent
triage_agent = Agent(client=client,
triage_agent = Agent(
client=client,
name="triage",
instructions=(
"You are a triage agent. Analyze the user's request and route to the appropriate specialist:\n"
@@ -125,7 +126,8 @@ async def run_agent_framework() -> None:
)
# Create billing specialist
billing_agent = Agent(client=client,
billing_agent = Agent(
client=client,
name="billing_agent",
instructions="You are a billing specialist. Help with payment and billing questions. Provide clear assistance.",
description="Handles billing and payment questions",
@@ -133,7 +135,8 @@ async def run_agent_framework() -> None:
)
# Create technical support specialist
tech_support = Agent(client=client,
tech_support = Agent(
client=client,
name="technical_support",
instructions="You are technical support. Help with technical issues. Provide clear assistance.",
description="Handles technical support questions",
@@ -73,7 +73,8 @@ async def run_agent_framework() -> None:
# Create agent with tool
client = OpenAIChatClient(model="gpt-4.1-mini")
agent = Agent(client=client,
agent = Agent(
client=client,
name="assistant",
instructions="You are a helpful assistant. Use available tools to answer questions.",
tools=[get_weather],
@@ -61,7 +61,8 @@ async def run_agent_framework() -> None:
client = OpenAIChatClient(model="gpt-4.1-mini")
# Create specialized writer agent
writer = Agent(client=client,
writer = Agent(
client=client,
name="writer",
instructions="You are a creative writer. Write short, engaging content.",
)
@@ -75,7 +76,8 @@ async def run_agent_framework() -> None:
)
# Create coordinator agent with writer tool
coordinator = Agent(client=client,
coordinator = Agent(
client=client,
name="coordinator",
instructions="You coordinate with specialized agents. Delegate writing tasks to the writer agent.",
tools=[writer_tool],
@@ -78,12 +78,14 @@ async def sk_agent_response_callback(
async def run_agent_framework_example(prompt: str) -> list[Message]:
client = OpenAIChatCompletionClient(credential=AzureCliCredential())
writer = Agent(client=client,
writer = Agent(
client=client,
instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."),
name="writer",
)
reviewer = Agent(client=client,
reviewer = Agent(
client=client,
instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."),
name="reviewer",
)
@@ -30,11 +30,7 @@ for _mod_name in _MISSING_MODULES:
# Load the two sample modules by file path to avoid needing them on sys.path.
# ---------------------------------------------------------------------------
_RESPONSES_DIR = (
Path(__file__).parent.parent.parent.parent
/ "samples"
/ "04-hosting"
/ "foundry-hosted-agents"
/ "responses"
Path(__file__).parent.parent.parent.parent / "samples" / "04-hosting" / "foundry-hosted-agents" / "responses"
)
+213 -195
View File
@@ -2,17 +2,20 @@ version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version < '3.11' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version < '3.11' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -145,24 +148,24 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = "
[package.metadata.requires-dev]
dev = [
{ name = "azure-monitor-opentelemetry", specifier = "==1.8.7" },
{ name = "azure-monitor-opentelemetry", specifier = "==1.8.8" },
{ name = "flit", specifier = "==3.12.0" },
{ name = "mcp", extras = ["ws"], specifier = "==1.27.0" },
{ name = "mcp", extras = ["ws"], specifier = "==1.27.2" },
{ name = "mypy", specifier = "==1.20.0" },
{ name = "opentelemetry-sdk", specifier = "==1.40.0" },
{ name = "poethepoet", specifier = "==0.42.1" },
{ name = "prek", specifier = "==0.3.9" },
{ name = "poethepoet", specifier = "==0.46.0" },
{ name = "prek", specifier = "==0.4.3" },
{ name = "pyright", specifier = "==1.1.408" },
{ name = "pytest", specifier = "==9.0.3" },
{ name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "pytest-asyncio", specifier = "==1.4.0" },
{ name = "pytest-cov", specifier = "==7.1.0" },
{ name = "pytest-retry", specifier = "==1.7.0" },
{ name = "pytest-timeout", specifier = "==2.4.0" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" },
{ name = "rich", specifier = ">=13.7.1,<16.0.0" },
{ name = "ruff", specifier = "==0.15.8" },
{ name = "ruff", specifier = "==0.15.15" },
{ name = "tomli", specifier = "==2.4.1" },
{ name = "uv", specifier = "==0.11.6" },
{ name = "uv", specifier = "==0.11.17" },
]
[[package]]
@@ -434,7 +437,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
version = "1.0.0b260528"
version = "1.0.0rc1"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -457,7 +460,7 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20260518" }]
[[package]]
name = "agent-framework-devui"
@@ -522,7 +525,7 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" }]
[[package]]
name = "agent-framework-foundry"
@@ -697,16 +700,16 @@ provides-extras = ["gaia", "lightning", "tau2", "math"]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = "==1.20.0" },
{ name = "poethepoet", specifier = "==0.42.1" },
{ name = "prek", specifier = "==0.3.9" },
{ name = "poethepoet", specifier = "==0.46.0" },
{ name = "prek", specifier = "==0.4.3" },
{ name = "pyright", specifier = "==1.1.408" },
{ name = "pytest", specifier = "==9.0.3" },
{ name = "rich", specifier = ">=13.7.1,<15.0.0" },
{ name = "ruff", specifier = "==0.15.8" },
{ name = "ruff", specifier = "==0.15.15" },
{ name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" },
{ name = "tomli", specifier = "==2.4.1" },
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "uv", specifier = "==0.11.6" },
{ name = "uv", specifier = "==0.11.17" },
]
[[package]]
@@ -1351,7 +1354,7 @@ wheels = [
[[package]]
name = "azure-monitor-opentelemetry"
version = "1.8.7"
version = "1.8.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1368,14 +1371,14 @@ dependencies = [
{ name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/42/ea67bebb400a7561b1ad1dd59d06b67e880daf8081ec0d41d3b0ce8fcc26/azure_monitor_opentelemetry-1.8.7.tar.gz", hash = "sha256:d0a430c69451f8fa09362769d2d65471713989fb78e4ad0f50832b597921efbb", size = 76970, upload-time = "2026-03-19T21:43:57.056Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/9e/3e63aa6cf8a46d06090b6f0046da6a59c470ffaf9968430867fa4a3c2eac/azure_monitor_opentelemetry-1.8.8.tar.gz", hash = "sha256:c6478cac82939230e9af1004b0a147e39b9046a564f3811d65241797f2f9d41d", size = 77532, upload-time = "2026-05-14T16:21:44.796Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/22/245a4f75a834430759a6fab9c5ab10e18719786ae684cf234c7bb6a693d1/azure_monitor_opentelemetry-1.8.7-py3-none-any.whl", hash = "sha256:0d3a228a183d76cf22698a3eed6e836d1cf57608b8ee879c634609b26f384eb2", size = 41268, upload-time = "2026-03-19T21:43:58.188Z" },
{ url = "https://files.pythonhosted.org/packages/ca/31/eb0cbb6771b222fddc520c30a7a8005e8537470131910c089ece37c82655/azure_monitor_opentelemetry-1.8.8-py3-none-any.whl", hash = "sha256:8c0d3095785ca8297d727181bef8dc0341f318fae750ad63af47722bbc56096f", size = 41371, upload-time = "2026-05-14T16:21:45.949Z" },
]
[[package]]
name = "azure-monitor-opentelemetry-exporter"
version = "1.0.0b51"
version = "1.0.0b52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1385,9 +1388,9 @@ dependencies = [
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/a4/a6cd2d389bc1009300bcd57c9e2ace4b7e7ae1e5dc0bda415ee803629cf2/azure_monitor_opentelemetry_exporter-1.0.0b51.tar.gz", hash = "sha256:a6171c34326bcd6216938bb40d715c15f1f22984ac1986fc97231336d8ac4c3c", size = 319837, upload-time = "2026-04-06T21:45:46.378Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/7e/bfc03436b88c48f5adc21a3ebbf4392b6b7fbbfe33ef3b1e88d07ba9f380/azure_monitor_opentelemetry_exporter-1.0.0b52.tar.gz", hash = "sha256:7eac679fca32dee9e426df65f2a538161db4514fc322fc66107f7826567d86e1", size = 326179, upload-time = "2026-05-11T22:47:02.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/1a/6b0b7a6181b42709103a65a676c89fd5055cb1d1b281ebe10c49254a170f/azure_monitor_opentelemetry_exporter-1.0.0b51-py2.py3-none-any.whl", hash = "sha256:6572cac11f96e3b18ae1187cb35cf3b40d0004655dae8048896c41c765bea530", size = 242104, upload-time = "2026-04-06T21:45:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/a4/e8/d13e6a74c98ecc3011bce9ab09fc2e75aec48ab46288f72be57c2fa21460/azure_monitor_opentelemetry_exporter-1.0.0b52-py2.py3-none-any.whl", hash = "sha256:a38c503e5e2cc0ec8a4bf336b23cce23488719f5361a45cdd01a514080f0e7fc", size = 244751, upload-time = "2026-05-11T22:47:04.304Z" },
]
[[package]]
@@ -1798,15 +1801,18 @@ name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -3073,11 +3079,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
]
[[package]]
@@ -3445,87 +3451,87 @@ wheels = [
[[package]]
name = "librt"
version = "0.8.1"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" },
{ url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" },
{ url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" },
{ url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" },
{ url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" },
{ url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
{ url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
{ url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
{ url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
{ url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" },
{ url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" },
{ url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" },
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
{ url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" },
{ url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" },
{ url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" },
{ url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" },
{ url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" },
{ url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" },
{ url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" },
{ url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" },
{ url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" },
{ url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" },
{ url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
]
[[package]]
@@ -3786,7 +3792,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.27.0"
version = "1.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3804,9 +3810,9 @@ dependencies = [
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
]
[package.optional-dependencies]
@@ -4292,15 +4298,18 @@ name = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -4963,15 +4972,18 @@ name = "pandas"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -5150,11 +5162,11 @@ wheels = [
[[package]]
name = "pip"
version = "26.0.1"
version = "26.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
{ url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" },
]
[[package]]
@@ -5181,16 +5193,16 @@ wheels = [
[[package]]
name = "poethepoet"
version = "0.42.1"
version = "0.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pastel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/9b/e717572686bbf23e17483389c1bf3a381ca2427c84c7e0af0cdc0f23fccc/poethepoet-0.42.1.tar.gz", hash = "sha256:205747e276062c2aaba8afd8a98838f8a3a0237b7ab94715fab8d82718aac14f", size = 93209, upload-time = "2026-02-26T22:57:50.883Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/f5/d501fcb67e450fd3fae9db06050420c0c6043758cfa8c30ba40278211265/poethepoet-0.46.0.tar.gz", hash = "sha256:daf8469031879ef59ef0b34fdba83574d65e41eb9186e20cd0f7c89ce479b030", size = 117276, upload-time = "2026-05-15T15:52:02.548Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/68/75fa0a5ef39718ea6ba7ab6a3d031fa93640e57585580cec85539540bb65/poethepoet-0.42.1-py3-none-any.whl", hash = "sha256:d8d1345a5ca521be9255e7c13bc2c4c8698ed5e5ac5e9e94890d239fcd423d0a", size = 119967, upload-time = "2026-02-26T22:57:49.467Z" },
{ url = "https://files.pythonhosted.org/packages/af/01/a9d6ea30351919298d3dc237172ae6a60ce0224ecaaee289b671ab979c13/poethepoet-0.46.0-py3-none-any.whl", hash = "sha256:dc6d770a14792d124abac9066c5a707876027d1878ac9ca26cf57e9b2a96dc89", size = 150581, upload-time = "2026-05-15T15:52:01.118Z" },
]
[[package]]
@@ -5265,26 +5277,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.3.9"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/ff/5b7a2a9c4fa3dd2ffc8b13a9ec22aa550deda5b39ab273f8e02863b12642/prek-0.3.9.tar.gz", hash = "sha256:f82b92d81f42f1f90a47f5fbbf492373e25ef1f790080215b2722dd6da66510e", size = 423801, upload-time = "2026-04-13T12:30:38.191Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/3b/a0ae60bbd4c4735f20aeddfbd3c50fb669cd8e99c078a3ed75a6a4a5c6d7/prek-0.4.3.tar.gz", hash = "sha256:e486307ea649e7300b3535fac52fe0ba0b80aebe23143b662659d16e6a7c8b47", size = 461800, upload-time = "2026-05-27T03:18:58.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/08/c11a6b7834b461223763b6b1552f32c9199393685d52d555de621e900ee7/prek-0.3.9-py3-none-linux_armv6l.whl", hash = "sha256:3ed793d51bfaa27bddb64d525d7acb77a7c8644f549412d82252e3eb0b88aad8", size = 5337784, upload-time = "2026-04-13T12:30:46.044Z" },
{ url = "https://files.pythonhosted.org/packages/15/d9/974b02832a645c6411069c713e3191ce807f9962006da108e4727efd2fa1/prek-0.3.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:399c58400c0bd0b82a93a3c09dc1bfd88d8d0cfb242d414d2ed247187b06ead1", size = 5713864, upload-time = "2026-04-13T12:30:27.007Z" },
{ url = "https://files.pythonhosted.org/packages/40/e1/4ed14bef15eb30039a75177b0807ac007095a5a110284706ccf900a8d512/prek-0.3.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ea1ffb124e92f081b8e2ca5b5a623a733efb3be0c5b1f4b7ffe2ee17d1f20c", size = 5290437, upload-time = "2026-04-13T12:30:30.658Z" },
{ url = "https://files.pythonhosted.org/packages/67/80/d5c3015e9da161dede566bfeef41f098f92470613157daa4f7377ab08d58/prek-0.3.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aaf639f95b7301639298311d8d44aad0d0b4864e9736083ad3c71ce9765d37ab", size = 5536208, upload-time = "2026-04-13T12:30:47.964Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/8cdc5eb1018437d7828740defd322e7a96459c02fc8961160c4120325313/prek-0.3.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff104863b187fa443ea8451ca55d51e2c6e94f99f00d88784b5c3c4c623f1ebe", size = 5251785, upload-time = "2026-04-13T12:30:39.78Z" },
{ url = "https://files.pythonhosted.org/packages/bd/e2/a5fc35a0fd3167224a000ca1b6235ecbdea0ac77e24af5979a75b0e6b5a4/prek-0.3.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039ecaf87c63a3e67cca645ebd5bc5eb6aafa6c9d929e9a27b2921e7849d7ef9", size = 5668548, upload-time = "2026-04-13T12:30:24.914Z" },
{ url = "https://files.pythonhosted.org/packages/09/e8/a189ee79f401c259f66f8af587f899d4d5bfb04e0ca371bfd01e49871007/prek-0.3.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bde2a3d045705095983c7f78ba04f72a7565fe1c2b4e85f5628502a254754ff", size = 6660927, upload-time = "2026-04-13T12:30:44.495Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5a/54117316e98ff62a14911ad1488a3a0945530242a2ce3e92f7a40b6ccc02/prek-0.3.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0960a21543563e2c8e19aaad176cc8423a87aac3c914d0f313030d7a9244a", size = 5932244, upload-time = "2026-04-13T12:30:49.532Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/e88d4361f59be7adeeb3a8a3819d69d286d86fe6f7606840af6734362675/prek-0.3.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb5d5171d7523271909246ee306b4dc3d5b63752e7dd7c7e8a8908fc9490d1", size = 5542139, upload-time = "2026-04-13T12:30:41.266Z" },
{ url = "https://files.pythonhosted.org/packages/11/1f/204837115087bb8d063bda754a7fe975428c5d5b6548c30dd749f8ab85d4/prek-0.3.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82b791bd36c1430c84d3ae7220a85152babc7eaf00f70adcb961bd594e756ba3", size = 5392519, upload-time = "2026-04-13T12:30:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/bd/00/de57b5795e670b6d38e7eda6d9ac6fd6d757ca22f725e5054b042104cd53/prek-0.3.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6eac6d2f736b041118f053a1487abed468a70dd85a8688eaf87bb42d3dcecf20", size = 5222780, upload-time = "2026-04-13T12:30:36.576Z" },
{ url = "https://files.pythonhosted.org/packages/f5/14/0bc055c305d92980b151f2ec00c14d28fe94c6d51180ca07fded28771cbf/prek-0.3.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5517e46e761367a3759b3168eabc120840ffbca9dfbc53187167298a98f87dc4", size = 5524310, upload-time = "2026-04-13T12:30:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d1/eebc2b69be0de36cd84adbe0a0710f4deb468a90e30525be027d6db02d54/prek-0.3.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:92024778cf78683ca32687bb249ab6a7d5c33887b5ee1d1a9f6d0c14228f4cf3", size = 6043751, upload-time = "2026-04-13T12:30:29.101Z" },
{ url = "https://files.pythonhosted.org/packages/46/cb/be98c04e702cbc0b0328cd745ff4634ace69ad5a84461bde36f88a7be873/prek-0.3.9-py3-none-win32.whl", hash = "sha256:7f89c55e5f480f5d073769e319924ad69d4bf9f98c5cb46a83082e26e634c958", size = 5045940, upload-time = "2026-04-13T12:30:42.882Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b6/b51771d69f6282e34edeb73f23d956da34f2cabbb5ba16ba175cc0a056f9/prek-0.3.9-py3-none-win_amd64.whl", hash = "sha256:7722f3372eaa83b147e70a43cb7b9fe2128c13d0c78d8a1cdbf2a8ec2ee071eb", size = 5435204, upload-time = "2026-04-13T12:30:51.482Z" },
{ url = "https://files.pythonhosted.org/packages/30/8a/f8a87c15b095460eccd67c8d89a086b7a37aac8d363f89544b8ce6ec653d/prek-0.3.9-py3-none-win_arm64.whl", hash = "sha256:0bced6278d6cc8a4b46048979e36bc9da034611dc8facd77ab123177b833a929", size = 5279552, upload-time = "2026-04-13T12:30:53.011Z" },
{ url = "https://files.pythonhosted.org/packages/df/be/980a0512f7eec3469dd40574f4e35d9ce7b67b358fea58888d13a0625b0d/prek-0.4.3-py3-none-linux_armv6l.whl", hash = "sha256:c67109de8d9766c2afd6e7e64feb9e1a0d3eceb3b4123280c28344660c1a97cd", size = 5541730, upload-time = "2026-05-27T03:19:09.119Z" },
{ url = "https://files.pythonhosted.org/packages/ef/55/937d707cc01d311e5c856c7019bc7db2c5e1835728396bb1ea32a7ecfdfd/prek-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b43a85f5ddf7827a75491e79ca068a49c5e4efde8dbac844ecb89622a78458e4", size = 5906762, upload-time = "2026-05-27T03:19:21.651Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6a/9a99ac481eb148dba55652df88b029ab6c1f90384bd51996026cdab2dafb/prek-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e99ee90a7b6e84dabef891ff7521eb59dae38953467bdb482f004ea522d3a64c", size = 5461541, upload-time = "2026-05-27T03:18:55.984Z" },
{ url = "https://files.pythonhosted.org/packages/a4/8d/9056b02a100cc18b101fc05ecc82635889f5f8cb1cce5d70b027e517a6d9/prek-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f514ec0d95cd4578d74d4601058bd259f5baf91c937f2aaae942d4b070b8077f", size = 5720501, upload-time = "2026-05-27T03:18:52.424Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ea/efbe4523e53022d94272ddfdd3a198ace7de004dd8830a69318085a10393/prek-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03a4ac3c3023a76faa52ad7775720599b10241930be8902c471085b22572b4b0", size = 5452412, upload-time = "2026-05-27T03:19:17.801Z" },
{ url = "https://files.pythonhosted.org/packages/97/d6/8a48b2c6a5117110d688c2d8ca2526264ad9f0d3baed4587038ee85e4c2d/prek-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40173425ab82bf0a7267d672b3e3aae9dd425eaee3a3641c6a5f040da3ff95e4", size = 5849515, upload-time = "2026-05-27T03:19:05.545Z" },
{ url = "https://files.pythonhosted.org/packages/ec/66/ccce7a1b6c6b610a22b54092d523ea7d35709e42864dace3734c05dd5f98/prek-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b8d99ee3277f8f3a3453a953120ee5c6c52f7ad89e459a25425cf62135f47b1", size = 6743978, upload-time = "2026-05-27T03:19:07.445Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f8/7a441d780c42e858ad677c82bb54eb3f01b424b710a8db5b9a8782305326/prek-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08595fe96d24c1fe13486b00d55ce73a7b37040a16e82365942606594c67a6b", size = 6108774, upload-time = "2026-05-27T03:19:03.565Z" },
{ url = "https://files.pythonhosted.org/packages/bf/38/fbb1afe14c7536109c68a1d9ca602f152f1929972d006c517c3b92140192/prek-0.4.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8607d636ef9232675507d97d252e1dcca5628bff79cb069fa945fff09d7bbb43", size = 5723165, upload-time = "2026-05-27T03:18:59.558Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b8/edafebce2bbd85f9e9de2781c225d690eb2b9897a06b224f5c24658fe398/prek-0.4.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:89484765304a779780f83489eb3aed5de5366f47fce7713fa5a917ebc281baa0", size = 5560557, upload-time = "2026-05-27T03:18:49.59Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2e/a85a40458ac50c452cae2ddd2eed0b70107fd2b4074d7a5003088ac508f1/prek-0.4.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:2d2b0c12e3d1c6d90646f9faa2d4c66f9861f3c6e577d7dbd25e733ed095ac56", size = 5417874, upload-time = "2026-05-27T03:19:01.681Z" },
{ url = "https://files.pythonhosted.org/packages/4c/be/106fb026646e1da65da6d2a5f3cfbda817e68a72429645351b7033c0b2b5/prek-0.4.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ca6802eaf191acb6166e9e013dd277ea193ba27c1dca896ab7debf6dca758b6d", size = 5710013, upload-time = "2026-05-27T03:18:54.143Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/edfff5f7d9b6c9e5860dfe05c9488e1b96de990b652db2e379d45af8ad2e/prek-0.4.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a46862d81078d2c8caa286c392f965ed72fb72eb1fed171910ba54fe8d546ed0", size = 6230160, upload-time = "2026-05-27T03:19:15.58Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/70adf26d5da0b7a66d8e284a661feddd5e8c69784b82084f40485fa321e4/prek-0.4.3-py3-none-win32.whl", hash = "sha256:f78e343584cfff106fc3c361109b87949ad8028dc5aa667e0fccd26db8170d7d", size = 5226844, upload-time = "2026-05-27T03:19:13.871Z" },
{ url = "https://files.pythonhosted.org/packages/32/5d/9f21aca8ccee6978db831dbf36c2e17461692c75dd291c9b3d170e39a82a/prek-0.4.3-py3-none-win_amd64.whl", hash = "sha256:798d04437d30d6b4e6c1d520fe6ca800c340c9246f0dc8900d8b365df54b71b6", size = 5616068, upload-time = "2026-05-27T03:19:19.653Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ef/cad8f9c66bcc199e22d1ad82a50032067a4c8b4182306d3472ff99f64aa3/prek-0.4.3-py3-none-win_arm64.whl", hash = "sha256:70d9da5fc14ef41565ff7ba9f476fb53166bf719a954339b2e9f42ed494a2f71", size = 5448057, upload-time = "2026-05-27T03:19:11.118Z" },
]
[[package]]
@@ -5888,16 +5900,16 @@ wheels = [
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
@@ -6477,27 +6489,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.8"
version = "0.15.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
]
[[package]]
@@ -6566,15 +6578,18 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -6691,15 +6706,18 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.14.*' and sys_platform == 'darwin'",
"python_full_version == '3.13.*' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and sys_platform == 'linux'",
"python_full_version == '3.13.*' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
@@ -7298,20 +7316,20 @@ wheels = [
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260402"
version = "2.9.0.20260518"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/30/c5d9efbff5422b20c9551dc5af237d1ab0c3d33729a9b3239a876ca47dd4/types_python_dateutil-2.9.0.20260402.tar.gz", hash = "sha256:a980142b9966713acb382c467e35c5cc4208a2f91b10b8d785a0ae6765df6c0b", size = 16941, upload-time = "2026-04-02T04:18:35.834Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/e8/c01bdf0d7c3659428c091fbd693177093639565bcbc86bc20098e6d37cc6/types_python_dateutil-2.9.0.20260518.tar.gz", hash = "sha256:51f02dc03b61c7f6a07df45797d4dfe8a1aa47f0b7db9ad89f6fd3a1a70e1b51", size = 17082, upload-time = "2026-05-18T06:05:24.508Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/d7/fe753bf8329c8c3c1addcba1d2bf716c33898216757abb24f8b80f82d040/types_python_dateutil-2.9.0.20260402-py3-none-any.whl", hash = "sha256:7827e6a9c93587cc18e766944254d1351a2396262e4abe1510cbbd7601c5e01f", size = 18436, upload-time = "2026-04-02T04:18:34.806Z" },
{ url = "https://files.pythonhosted.org/packages/36/22/169273273ca34e9ab0ae2f387ba72ed7e09faaaf834da01d6b89c2bea71a/types_python_dateutil-2.9.0.20260518-py3-none-any.whl", hash = "sha256:d6a9c5bd0de61460c8fdef8ab2b400f956a1a1075cce08d4e2b4434e478c50b8", size = 18431, upload-time = "2026-05-18T06:05:23.641Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
version = "6.0.12.20260518"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
{ url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" },
]
[[package]]
@@ -7379,28 +7397,28 @@ wheels = [
[[package]]
name = "uv"
version = "0.11.6"
version = "0.11.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/8e/ec34c19d0f254fcbcc5c1ce8c7f06e47e0f69a7e1a0269c1d59cb0b0f279/uv-0.11.17.tar.gz", hash = "sha256:1d1be74deec997db1dda05a7e67541c904d65cbfd72e455d3c0a2a1e4bf2cddf", size = 4203607, upload-time = "2026-05-28T20:39:47.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
{ url = "https://files.pythonhosted.org/packages/15/2e/e6d42f9d39009eee976f1e5dfd31d3d1943e6e593ad7b191cf11e9744a36/uv-0.11.17-py3-none-linux_armv6l.whl", hash = "sha256:8426bfe315564d414cbc5ba5467595dc6348965e19acec742914f47da3ff269f", size = 23551216, upload-time = "2026-05-28T20:39:05.395Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ee/d72bcc60f3585653a4b768425854d737d98d65c1765547d25c2999547ea9/uv-0.11.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6d1a033cc68cabb4141d6c1e3b66ffc6e970b98ba42e210f33270251e0bd8697", size = 22997377, upload-time = "2026-05-28T20:39:25.21Z" },
{ url = "https://files.pythonhosted.org/packages/58/34/1bc69798d9ae998fbc42c61b02883f2ba00d04bdd858e589604d01846287/uv-0.11.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:58c07ffc272c847d29cd98ca5082fa4304a645f87c718ec900e3cca9026bd096", size = 21630197, upload-time = "2026-05-28T20:39:28.935Z" },
{ url = "https://files.pythonhosted.org/packages/6b/93/1be48ec6a8933d9a77d0ce5240ed63f68869f68517ccf5d62268ed03f3e8/uv-0.11.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:036d6e2940afe8b79637530b01b9241d8cfd174b07f1179a1ebbd42409c38ca3", size = 23414940, upload-time = "2026-05-28T20:39:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/00/31/b7488ff49d80090ea9d05d67a4d381a1b4479502e9853e654caa1c1c678e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:283186700c3e65a4644a73a917232da7d3e4a94d25ea0377a44f5b263fa49577", size = 23096330, upload-time = "2026-05-28T20:39:01.284Z" },
{ url = "https://files.pythonhosted.org/packages/fe/95/42b6137c5de06278d229c7eef2f314df2a738cd799795bbb44dace21bd6e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2e44dfbfc7778d0d90edc6738f237c91e5e37e4e3cfe94c8a312cec56a41485", size = 23101906, upload-time = "2026-05-28T20:39:17.149Z" },
{ url = "https://files.pythonhosted.org/packages/17/7c/0ca03b2d19965db6d5dfe0c8cf96a3d0b424503c8cbc3cd2ffdc5869a15d/uv-0.11.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a817eeb3026f27a53d3f4b7855a5105f6787dd192140e201eda4d2b9a11b72e", size = 24444409, upload-time = "2026-05-28T20:39:59.218Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fb/179f55a3b19d47c30ec1f41b9b964da74dfa7053ff310a70a9c4d8cb998d/uv-0.11.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf8f5ad959583dcd2c4ae445c754a97c05700246ff89259f3fd285c9c20f4c00", size = 25540153, upload-time = "2026-05-28T20:39:09.535Z" },
{ url = "https://files.pythonhosted.org/packages/f7/29/592f42012765c43ae45c112110e214bca7b0cfc08c4c1b52e1dfa47dedd5/uv-0.11.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce16892a45134d20165c1ceababe06f3e9ce6a58902db1eff812c8c93626823f", size = 24665906, upload-time = "2026-05-28T20:39:41.254Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/b75808766f895248553c6370968509cd4f726e6943e310a8f7a171036ad0/uv-0.11.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da839e5a491c9a701d7d327a199cafc76ac27a03ac84fd2a8d4bf32c3af2448", size = 24863325, upload-time = "2026-05-28T20:39:51.006Z" },
{ url = "https://files.pythonhosted.org/packages/ee/6a/6f27ee69e97f480104bb8ec335f04c2a12add98edfcc4844a68e9538b6e2/uv-0.11.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ec004b3c9bf9cb7756067ad1bd0bf64eb843e6fa2edbfbb3135ee152c14cea91", size = 23521674, upload-time = "2026-05-28T20:38:55.869Z" },
{ url = "https://files.pythonhosted.org/packages/df/11/1344aca7c710f794750f74de0e552a54ab24193ecc01fa3b3ae22ff822a1/uv-0.11.17-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:659227cac719b618cc91e02be9e274ad5bd72d74fa278123e6373537e9f28216", size = 24224725, upload-time = "2026-05-28T20:39:32.945Z" },
{ url = "https://files.pythonhosted.org/packages/ad/44/7b11550c1453ea13b81e549c83523e6ab6ed3231d09b2fd6b9eb19acceaf/uv-0.11.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e301d844eed9401f0f0351de12c55f1306ca05372acb0f28d35717c8ba663a22", size = 24301643, upload-time = "2026-05-28T20:39:45.183Z" },
{ url = "https://files.pythonhosted.org/packages/1a/36/8f683bc60547b8f93d0e752a8574d13fad776999cb978482b360c053ca22/uv-0.11.17-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f0bf483c0d9fa14283992d56061b498b9d3d4adebd285af8744dc33f64dadfba", size = 23786049, upload-time = "2026-05-28T20:39:20.999Z" },
{ url = "https://files.pythonhosted.org/packages/10/dc/7a495db39c2970de4fa375c337dbd617b16780911f88f0511f8fe7f6747c/uv-0.11.17-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:2ccd5487a4a192bc832ea04c867a26883757db8fdfe88bed85d8129c82f9e505", size = 25049786, upload-time = "2026-05-28T20:40:03.292Z" },
{ url = "https://files.pythonhosted.org/packages/37/dd/74eff72d749eaf7e19f489878e21a368a7fef58d26ea0c63ec044ecd78b1/uv-0.11.17-py3-none-win32.whl", hash = "sha256:12b701fa32c5be3691759a73956e4462f30fa7b0dfa52ec66cb305bbb6ea4129", size = 22479213, upload-time = "2026-05-28T20:39:13.316Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/8af4a92b99a8a4823297c26df727fe957267e03e1196e3caa803c3f6ccb2/uv-0.11.17-py3-none-win_amd64.whl", hash = "sha256:44ec1fe3af839f87370dcf0400c0cab917cc1ce697d563e860fc7d9ed72655e7", size = 25083161, upload-time = "2026-05-28T20:40:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/00/76/a689077832d585d29d87f9cd0d65eca1af58abd29a4eab004d0a8a858b9c/uv-0.11.17-py3-none-win_arm64.whl", hash = "sha256:37c915bfcf86f99c1c5be7c9ed21e0d80624067ba47bc8916a3cb0530bc94d27", size = 23544936, upload-time = "2026-05-28T20:39:37.137Z" },
]
[[package]]