diff --git a/.chronus/changes/agentserver-conversation-engine-20261015.md b/.chronus/changes/agentserver-conversation-engine-20261015.md new file mode 100644 index 000000000000..cf2219b6cf8c --- /dev/null +++ b/.chronus/changes/agentserver-conversation-engine-20261015.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - azure-ai-agentserver-invocations +--- + +Added a standalone `/voice_conversation_engine` WebSocket route to `VoiceAgentServerHost`, reusing the typed Bridge event relay alongside its unchanged `/invocations_ws` route without new constructor options. Generic `InvocationAgentServerHost` raw WebSocket handlers are unchanged. diff --git a/.chronus/changes/agentserver-steering-current-input.md b/.chronus/changes/agentserver-steering-current-input.md new file mode 100644 index 000000000000..92c1c381961d --- /dev/null +++ b/.chronus/changes/agentserver-steering-current-input.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - sdk/agentserver/azure-ai-agentserver-core +--- + +Fixed queued steering turns being skipped by Responses ownership checks by atomically persisting the promoted turn as the current task input before execution. Attachment-backed inputs retain the existing input offload behavior. diff --git a/.chronus/changes/agentserver-user-barge-in-20261019.md b/.chronus/changes/agentserver-user-barge-in-20261019.md new file mode 100644 index 000000000000..3c44825f6b88 --- /dev/null +++ b/.chronus/changes/agentserver-user-barge-in-20261019.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - azure-ai-agentserver-invocations +--- + +Aligned the experimental Voice caller-interruption contract with `user.barge_in`, `UserBargeIn`, `UserBargeInCallback`, and `VoiceAgentServerHost.on_user_barge_in`. Playback snapshots and application-owned cancellation/history are preserved. Voice Live senders must use the same event contract. Generic raw WebSocket handlers are unchanged. diff --git a/.chronus/changes/azure-ai-projects-zero-temperature-tracing-2026-10-27.md b/.chronus/changes/azure-ai-projects-zero-temperature-tracing-2026-10-27.md new file mode 100644 index 000000000000..fef3287c4e99 --- /dev/null +++ b/.chronus/changes/azure-ai-projects-zero-temperature-tracing-2026-10-27.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - sdk/ai/azure-ai-projects +--- + +Fixed agent creation tracing to record the `gen_ai.request.temperature` and `gen_ai.request.top_p` span attributes when their value is `0`. diff --git a/.chronus/changes/azure-identity-1-27-stable.md b/.chronus/changes/azure-identity-1-27-stable.md new file mode 100644 index 000000000000..225ee54f9e47 --- /dev/null +++ b/.chronus/changes/azure-identity-1-27-stable.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - sdk/identity/azure-identity +--- + +Prepare the 1.27.0 stable release metadata and document AKS identity binding configuration. The existing release entries remain in CHANGELOG.md. diff --git a/.chronus/changes/pyamqp-incoming-window-recovery.md b/.chronus/changes/pyamqp-incoming-window-recovery.md new file mode 100644 index 000000000000..9449483ac013 --- /dev/null +++ b/.chronus/changes/pyamqp-incoming-window-recovery.md @@ -0,0 +1,8 @@ +--- +changeKind: fix +packages: + - sdk/servicebus/azure-servicebus + - sdk/eventhub/azure-eventhub +--- + +Fixed pyAMQP receive-window recovery after a link handler raises or is cancelled. Replenishment happens after delivery and only while the session is mapped, so closing sessions do not advertise new receive capacity. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7f6d3bdd67b0..0d754896e3e9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -301,10 +301,10 @@ /sdk/loadtesting/azure-developer-loadtesting/ @mitsha-microsoft @ninallam @prativen # PRLabel: %Machine Learning -/sdk/ml/ @achauhan-scc @jayesh-tanna @kingernupur +/sdk/ml/ @achauhan-scc @jayesh-tanna @kingernupur @novaturient95 @viragjain611 # PRLabel: %Machine Learning -/sdk/ml/azure-ai-ml/ @achauhan-scc @arunsu @jayesh-tanna @jialiu103 @JustinFirsching @kingernupur @nick863 @NonStatic2014 @novaturient95 @PhaniShekhar @raduk @rtanase @sharma-riti @vivram @yuanzhuangyuanzhuang +/sdk/ml/azure-ai-ml/ @achauhan-scc @arunsu @jayesh-tanna @jialiu103 @JustinFirsching @kingernupur @nick863 @NonStatic2014 @novaturient95 @PhaniShekhar @raduk @rtanase @sharma-riti @viragjain611 @vivram @yuanzhuangyuanzhuang # ServiceLabel: %Machine Learning # ServiceOwners: @Azure/azure-ml-sdk @azureml-github @@ -752,10 +752,10 @@ # ServiceOwners: @antcp @AzureAppServiceCLI # PRLabel: %Service Bus -/sdk/servicebus/ @EldertGrootenboer @skarri-microsoft @j7nw4r @meetamitbhatia +/sdk/servicebus/ @EldertGrootenboer @skarri-microsoft @j7nw4r @meetamitbhatia @pranz1996 # ServiceLabel: %Service Bus -# ServiceOwners: @EldertGrootenboer @skarri-microsoft @j7nw4r @meetamitbhatia +# ServiceOwners: @EldertGrootenboer @skarri-microsoft @j7nw4r @meetamitbhatia @pranz1996 # PRLabel: %Service Fabric /sdk/servicefabric/ @QingChenmsft @samedder @@ -798,7 +798,7 @@ # Management Libraries #################### -/**/*mgmt*/ @ChenxiJiang333 @msyyc +/**/*mgmt*/ @ChenxiJiang333 @msyyc @Azure/azure-arm-sdk-reviewers # ServiceLabel: %ARM %Service Attention # ServiceOwners: @armleads-azure @@ -859,10 +859,10 @@ /eng/tools/emitter/ @tadelesh @msyyc @iscai-msft @xirzec @lirenhe @ChenxiJiang333 /pylintrc @JennyPng @l0lawrence @danieljurek @laiapat -/sdk/**/ci.yml @JennyPng @l0lawrence @msyyc @xirzec @danieljurek +/sdk/**/ci.yml @JennyPng @l0lawrence @msyyc @xirzec @danieljurek @Azure/azure-arm-sdk-reviewers # Add Johnathan Walker as code owner for Event Hubs SDK /sdk/eventhub/azure-eventhub/* @j7nw4r @meetamitbhatia @SwayGom @sagar0207 -# Add Johnathan Walker as code owner for Service Bus SDK -/sdk/servicebus/azure-servicebus/* @j7nw4r @meetamitbhatia @SwayGom @sagar0207 +# Service Bus SDK team code owners +/sdk/servicebus/azure-servicebus/* @j7nw4r @meetamitbhatia @SwayGom @sagar0207 @pranz1996 @EldertGrootenboer \ No newline at end of file diff --git a/.github/skills/azsdk-common-apiview-feedback-resolution/evals/eval.yaml b/.github/skills/azsdk-common-apiview-feedback-resolution/evals/eval.yaml index c464c3109cf2..b2473a5341b5 100644 --- a/.github/skills/azsdk-common-apiview-feedback-resolution/evals/eval.yaml +++ b/.github/skills/azsdk-common-apiview-feedback-resolution/evals/eval.yaml @@ -8,10 +8,10 @@ tags: area: azsdk-common-apiview-feedback-resolution type: ci-gate -config: +defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-apiview-feedback-resolution/evals/trigger.eval.yaml b/.github/skills/azsdk-common-apiview-feedback-resolution/evals/trigger.eval.yaml index 4baee7fd21fd..b7684015c912 100644 --- a/.github/skills/azsdk-common-apiview-feedback-resolution/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-apiview-feedback-resolution/evals/trigger.eval.yaml @@ -11,7 +11,7 @@ tags: defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-generate-sdk-locally/evals/eval.yaml b/.github/skills/azsdk-common-generate-sdk-locally/evals/eval.yaml index ca7f153a1cc7..5856f2c07a64 100644 --- a/.github/skills/azsdk-common-generate-sdk-locally/evals/eval.yaml +++ b/.github/skills/azsdk-common-generate-sdk-locally/evals/eval.yaml @@ -8,10 +8,10 @@ tags: area: azsdk-common-generate-sdk-locally type: ci-gate -config: +defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-generate-sdk-locally/evals/trigger.eval.yaml b/.github/skills/azsdk-common-generate-sdk-locally/evals/trigger.eval.yaml index 2ebf85d89760..b33524cda990 100644 --- a/.github/skills/azsdk-common-generate-sdk-locally/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-generate-sdk-locally/evals/trigger.eval.yaml @@ -20,6 +20,10 @@ scoring: stimuli: - name: trigger-generate-python-sdk-locally prompt: "Generate the Python SDK locally from my TypeSpec project at specification/compute/resource-manager/Microsoft.Compute/ComputeRP/tspconfig.yaml." + environment: + skills: + - .. + - ../../azsdk-common-sdk-breaking-change graders: - type: skill-invocation config: @@ -28,7 +32,12 @@ stimuli: # Should NOT trigger - name: anti-trigger-mitigate-breaking-change prompt: "Fix the SDK breaking changes" + environment: + skills: + - .. + - ../../azsdk-common-sdk-breaking-change graders: - type: skill-invocation config: + required: ["azsdk-common-sdk-breaking-change"] disallowed: ["azsdk-common-generate-sdk-locally"] diff --git a/.github/skills/azsdk-common-pipeline-analysis/evals/trigger.eval.yaml b/.github/skills/azsdk-common-pipeline-analysis/evals/trigger.eval.yaml index ec7e3d5c3dc7..68e32d14c6f9 100644 --- a/.github/skills/azsdk-common-pipeline-analysis/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-pipeline-analysis/evals/trigger.eval.yaml @@ -11,7 +11,7 @@ tags: defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-pipeline-fixer/evals/trigger.eval.yaml b/.github/skills/azsdk-common-pipeline-fixer/evals/trigger.eval.yaml index 1c769875c35f..f87c05ffbde8 100644 --- a/.github/skills/azsdk-common-pipeline-fixer/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-pipeline-fixer/evals/trigger.eval.yaml @@ -11,11 +11,11 @@ tags: defaults: runs: 1 # Stimuli with a resolvable build reference (URL or build ID) make the agent - # run the full analyze→fix→verify workflow (~90-180s under gpt-5.4), + # run the full analyze→fix→verify workflow (~90-180s under gpt-6.1-sol), # so the per-stimulus cap matches the Vally scenario evals (5m). Non-build-ref # routing prompts still complete in ~10-20s. timeout: "5m" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-prepare-release-plan/evals/eval.yaml b/.github/skills/azsdk-common-prepare-release-plan/evals/eval.yaml index ea7ed3db1968..615883a4d2e1 100644 --- a/.github/skills/azsdk-common-prepare-release-plan/evals/eval.yaml +++ b/.github/skills/azsdk-common-prepare-release-plan/evals/eval.yaml @@ -8,10 +8,10 @@ tags: area: azsdk-common-prepare-release-plan type: ci-gate -config: +defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-prepare-release-plan/evals/schedule-risk.eval.yaml b/.github/skills/azsdk-common-prepare-release-plan/evals/schedule-risk.eval.yaml index 145576bec41c..a87fdcf1f099 100644 --- a/.github/skills/azsdk-common-prepare-release-plan/evals/schedule-risk.eval.yaml +++ b/.github/skills/azsdk-common-prepare-release-plan/evals/schedule-risk.eval.yaml @@ -11,7 +11,7 @@ tags: defaults: runs: 1 timeout: "120s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: @@ -58,6 +58,7 @@ stimuli: config: substring: "https://github.com/Azure/azure-rest-api-specs/pull/12345" + # The new target stays future-proof; the mock's older plan supplies the warning. - name: create-relays-past-due-guidance environment: skills: @@ -65,7 +66,8 @@ stimuli: prompt: | Create a public preview release plan for TypeSpec project specification/contosowidgetmanager/Contoso.WidgetManager with target - release month June 2026, spec PR + release month next calendar month (resolve to an explicit month and year + using the current date), spec PR https://github.com/Azure/azure-rest-api-specs/pull/38387, service tree ID a7f2b8e4-9c1d-4a3e-b6f9-2d8e5a7c3b1f, and product tree ID f1a8c5d2-6e4b-4f7a-9c2d-8b5e1f3a6c9e. Use the Azure SDK MCP tools. diff --git a/.github/skills/azsdk-common-prepare-release-plan/evals/trigger.eval.yaml b/.github/skills/azsdk-common-prepare-release-plan/evals/trigger.eval.yaml index 02141eaea26f..919bfa214259 100644 --- a/.github/skills/azsdk-common-prepare-release-plan/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-prepare-release-plan/evals/trigger.eval.yaml @@ -11,7 +11,7 @@ tags: defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-common-sdk-breaking-change/evals/trigger.eval.yaml b/.github/skills/azsdk-common-sdk-breaking-change/evals/trigger.eval.yaml index 933bdcfb40f5..6a0ba6107815 100644 --- a/.github/skills/azsdk-common-sdk-breaking-change/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-sdk-breaking-change/evals/trigger.eval.yaml @@ -20,6 +20,9 @@ scoring: stimuli: - name: trigger-detect-sdk-breaking-changes-for-service prompt: "Detect and mitigate SDK breaking changes for the service webpubsub" + environment: + skills: + - .. graders: - type: skill-invocation config: diff --git a/.github/skills/azsdk-common-sdk-release/evals/eval.yaml b/.github/skills/azsdk-common-sdk-release/evals/eval.yaml index e910ffc92eb8..b73345200b0d 100644 --- a/.github/skills/azsdk-common-sdk-release/evals/eval.yaml +++ b/.github/skills/azsdk-common-sdk-release/evals/eval.yaml @@ -8,10 +8,10 @@ tags: area: azsdk-common-sdk-release type: ci-gate -config: +defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: @@ -60,12 +60,16 @@ stimuli: - name: basic-trigger prompt: "I need to release a new version of my Azure SDK package. Walk me through the process." + environment: + skills: + - .. graders: - type: output-contains config: substring: "release" - constraints: - expect_skills: ["azsdk-common-sdk-release"] + - type: skill-invocation + config: + required: ["azsdk-common-sdk-release"] - name: anti-trigger prompt: "How do I write unit tests for my Azure SDK client?" diff --git a/.github/skills/azsdk-common-sdk-release/evals/trigger.eval.yaml b/.github/skills/azsdk-common-sdk-release/evals/trigger.eval.yaml index a6fb4544cc66..629b6f6ebc01 100644 --- a/.github/skills/azsdk-common-sdk-release/evals/trigger.eval.yaml +++ b/.github/skills/azsdk-common-sdk-release/evals/trigger.eval.yaml @@ -11,7 +11,7 @@ tags: defaults: runs: 1 timeout: "90s" - model: gpt-5.4 + model: gpt-6.1-sol executor: copilot-sdk scoring: diff --git a/.github/skills/azsdk-python-mgmt-pr-triage/SKILL.md b/.github/skills/azsdk-python-mgmt-pr-triage/SKILL.md new file mode 100644 index 000000000000..884df24aa46e --- /dev/null +++ b/.github/skills/azsdk-python-mgmt-pr-triage/SKILL.md @@ -0,0 +1,117 @@ +--- +name: azsdk-python-mgmt-pr-triage +description: "Quickly list open Python management SDK AutoPRs for release rotators: automated review needs triggering, review already active, human review needed, changes after approval, and author follow-up after approval. Read-only; verify workflow activity before recommending a review trigger." +--- + +# Python management SDK PR triage + +Use this skill when a rotator asks for management SDK PR queues, PRs needing +attention, or whether an automated management review is already active. +Follow the [release rotation runbook](../../../doc/dev/mgmt/mgmt-release-rotation-runbook.md) +for review and release decisions; this skill only helps find PRs. + +## Prerequisites + +- Python 3.10+ and authenticated GitHub CLI (`gh`) on PATH. +- Read access to repository contents, pull requests, and Actions runs. +- Read access to CODEOWNERS team membership when team owners apply, except + the optional `Azure/azure-arm-sdk-reviewers` lookup described below. + +No SDK installation or local repository checkout is needed to run the bundled +script. In Copilot, ask: **"Use azsdk-python-mgmt-pr-triage to show management SDK +PRs needing attention."** If skills are not available in the current client, +run the script directly. + +## Run + +```powershell +python \scripts\triage_prs.py +``` + +Optional filters: + +```powershell +python \scripts\triage_prs.py --package azure-mgmt-iothub +python \scripts\triage_prs.py --version 1.0.0b1 +python \scripts\triage_prs.py --repo Azure/azure-sdk-for-python --limit 500 +``` + +Defaults: `Azure/azure-sdk-for-python`, at most 500 initially listed PRs, open +non-draft titles starting with `[AutoPR azure-mgmt-`. Include first releases. +Package and version filters are exact matches. Unknown versions are shown as +`unknown`; a version-filtered scan warns when it cannot classify a version. + +## Report + +Return the script's Markdown report and any diagnostic warnings, without +reinterpreting evidence as permission to approve or release. Preserve these +section numbers, omit empty sections, and sort each queue by most recently updated: + +1. **Automated review needs triggering**: the bot review is missing or its + linked run targets an older head, and no active review was detected. + Recommend adding `mgmt-review-needed`, or removing/reapplying it when present. +2. **Automated review active**: link queued, requested, waiting, pending, or + in-progress management review runs. Wait; do not request a duplicate, + including when the active run targets an older revision. +3. **Human review needed**: no effective approval from applicable CODEOWNERS, + or GitHub's review decision still requires review/changes. +4. **Changes after approval**: the current head differs from the effective + approved commit. Inspect the changes and current review requirements; + re-approve only if required. +5. **Author follow-up after approval**: link newer PR-author issue comments, + inline review comments, or submitted review bodies. Read and respond or + route the request; a comment does not itself require re-approval. + +Entries include PR link, package/version, reason, and next action. A PR may +appear in multiple queues. CODEOWNERS use GitHub file precedence and the last +matching rule for changed package files at the PR's base commit. Resolve team +members and use each maintainer's latest opinionated review; a later ordinary +comment does not invalidate an approval. + +If the `Azure/azure-arm-sdk-reviewers` membership lookup returns HTTP 403, +silently skip that team lookup and cache the failure for the scan. Keep +approvals from individually named CODEOWNERS and other readable teams. Use +GitHub's review decision for the human-review queue when a team-only approval +cannot be verified; do not claim an approval is missing solely because this +team is unreadable. Changes and author follow-ups after a team-only approval +cannot be classified unless the approver is also a verified maintainer. +Other team errors and API failures still produce verification warnings. + +## Evidence and limitations + +- Query the management review workflow, not generic CI. Use its run's explicit + `pull_requests` associations, not timestamps, title guesses, or `head_sha` + alone (`pull_request_target` can use a base revision). +- Fetch workflow runs with status-independent pagination and filter completed + runs locally, so transitions between active statuses do not hide a run. +- Compare the initial listing's `updatedAt` with the first PR read, then check + for changes again after collecting evidence. Reject changed snapshots rather + than combine an old review decision with newer reviews. +- Active runs without a PR association make activity verification incomplete. + Show **Review activity unknown** and the run links; never recommend + retriggering while activity cannot be verified. +- Identify the latest review comment by the GitHub Actions account, review + heading, and workflow marker/footer. Check the linked run's workflow identity + and associated PR head for freshness. An absent/unusable run link or head + makes freshness unknown, not outdated. Incomplete bot results remain for + human review; do not automatically rerun a current partial result. +- Activity is a point-in-time observation, not a lock. Follow the run link and + confirm no review is active immediately before changing a label. +- Unknown ownership, inaccessible APIs, scan limits, changed PRs during + collection, and missing version metadata produce explicit verification + warnings. A partial scan must not claim that no PRs need attention. +- Exit code `0` means collection completed without verification warnings; + `2` means the report is partial/unverified or collection failed. Read the + report and stderr rather than treating an incomplete scan as an empty queue. + +Never change labels, request reviews, post comments, approve, merge, or publish. +Holds, CI, release plans, release intent, and post-merge publication remain +manual runbook checks. Absence from a queue is not proof of release readiness. + +## Development + +Run fixture-based tests without GitHub access: + +```powershell +python -m unittest discover -s .github\skills\azsdk-python-mgmt-pr-triage\tests -v +``` diff --git a/.github/skills/azsdk-python-mgmt-pr-triage/scripts/triage_prs.py b/.github/skills/azsdk-python-mgmt-pr-triage/scripts/triage_prs.py new file mode 100644 index 000000000000..35c67d5d1e06 --- /dev/null +++ b/.github/skills/azsdk-python-mgmt-pr-triage/scripts/triage_prs.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Read-only management SDK PR queues for release rotators (Python 3.10+).""" + +from __future__ import annotations + +import argparse +import base64 +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import json +import re +import subprocess +import sys +from threading import RLock +from urllib.parse import quote + +WORKFLOW = "mgmt-sdk-pr-review.lock.yml" +ACTIVE_STATUSES = ("queued", "in_progress", "waiting", "pending", "requested") +PACKAGE = re.compile(r"^\[AutoPR (azure-mgmt-[^\]\s]+)\]") +SECTIONS = ( + "Automated review needs triggering", + "Automated review active", + "Human review needed", + "Changes after approval", + "Author follow-up after approval", +) + + +class CollectionError(RuntimeError): + """GitHub evidence could not be collected.""" + + +def gh(*args): + try: + result = subprocess.run( + ["gh", *args], capture_output=True, text=True, encoding="utf-8", timeout=90, check=False + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise CollectionError(f"GitHub CLI unavailable or timed out: {error}") from error + if result.returncode: + raise CollectionError(f"gh {' '.join(args)}: {result.stderr.strip()}") + try: + return json.loads(result.stdout) + except ValueError as error: + raise CollectionError("GitHub CLI returned invalid JSON.") from error + + +class GitHub: + def __init__(self, repo): + self.repo = repo + self.cache = {} + self.lock = RLock() + + def get(self, endpoint): + return gh("api", endpoint) + + def cached(self, endpoint): + with self.lock: + if endpoint not in self.cache: + try: + self.cache[endpoint] = self.get(endpoint) + except CollectionError as error: + self.cache[endpoint] = error + value = self.cache[endpoint] + if isinstance(value, CollectionError): + raise value + return value + + def pages(self, endpoint, key=None): + pages = gh("api", "--paginate", "--slurp", endpoint) + items = [item for page in pages for item in (page[key] if key else page)] + if key and pages and len(items) < pages[0].get("total_count", len(items)): + raise CollectionError(f"Incomplete pagination for {endpoint}.") + return items + + def owners(self, base_sha, files, package): + with self.lock: + cache_key = ("owners", base_sha) + if cache_key not in self.cache: + self.cache[cache_key] = self.load_rules(base_sha) + rules = self.cache[cache_key] + owners = set() + paths = [] + for file in files: + path = file["filename"] + if package in path.split("/"): + paths.append(file.get("previous_filename", path) if file["status"] == "renamed" else path) + if not paths: + raise CollectionError("No changed package files; cannot resolve maintainers.") + for path in paths: + matched = () + for pattern, candidates in rules: + if pattern.search(path): + matched = candidates + if not matched: + raise CollectionError(f"No CODEOWNERS for {path}.") + owners.update(matched) + maintainers = set() + skipped_team = False + for owner in owners: + if not owner.startswith("@"): + raise CollectionError(f"Cannot resolve CODEOWNERS identity {owner}.") + if "/" in owner: + org, team = owner[1:].split("/", 1) + with self.lock: + key = ("team", owner.casefold()) + if key not in self.cache: + try: + self.cache[key] = self.pages(f"orgs/{org}/teams/{team}/members?per_page=100") + except CollectionError as error: + self.cache[key] = error + members = self.cache[key] + if isinstance(members, CollectionError): + if owner.casefold() == "@azure/azure-arm-sdk-reviewers" and "HTTP 403" in str(members): + skipped_team = True + continue + raise members + maintainers.update(member["login"].casefold() for member in members) + else: + maintainers.add(owner[1:].casefold()) + return maintainers, sorted(owners, key=str.casefold), skipped_team + + def load_rules(self, base_sha): + for path in (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"): + try: + data = self.cached(f"repos/{self.repo}/contents/{path}?ref={quote(base_sha, safe='')}") + except CollectionError as error: + if "HTTP 404" in str(error): + continue + raise + if data.get("encoding") != "base64": + raise CollectionError(f"Cannot decode {path}.") + return parse_codeowners(base64.b64decode(data["content"]).decode("utf-8")) + raise CollectionError("No base-branch CODEOWNERS found.") + + +def parse_codeowners(text): + rules = [] + for line in text.splitlines(): + fields = line.split("#", 1)[0].split() + if not fields or fields[0].startswith("!") or any(char in fields[0] for char in "[]"): + continue + pattern = fields[0] + if "\\" in pattern: + raise CollectionError("Escaped CODEOWNERS patterns require manual ownership verification.") + rooted = pattern.startswith("/") or "/" in pattern.rstrip("/") + directory = pattern.endswith("/") + parts = pattern.strip("/").split("/") + expression = "^" if rooted else r"(?:^|/)" + for index, part in enumerate(parts): + if part == "**": + expression += ".*" if index == len(parts) - 1 else "(?:[^/]+/)*" + else: + expression += "".join( + "[^/]*" if char == "*" else "[^/]" if char == "?" else re.escape(char) for char in part + ) + if index < len(parts) - 1: + expression += "/" + expression += "/" if directory else r"(?:/|$)" + rules.append((re.compile(expression), tuple(fields[1:]))) + if not rules: + raise CollectionError("No usable CODEOWNERS rules.") + return rules + + +def timestamp(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def effective_approval(reviews, maintainers): + latest = {} + for review in sorted( + (item for item in reviews if item.get("submitted_at")), + key=lambda item: (timestamp(item["submitted_at"]), item["id"]), + ): + login = (review.get("user") or {}).get("login", "").casefold() + if login in maintainers and review["state"] in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"}: + latest[login] = review + approvals = [review for review in latest.values() if review["state"] == "APPROVED"] + return max(approvals, key=lambda review: timestamp(review["submitted_at"]), default=None) + + +def sdk_version(files): + for file in files: + if file["filename"].endswith("/_version.py"): + match = re.search(r"""^\+\s*VERSION\s*=\s*["']([^"']+)""", file.get("patch", ""), re.MULTILINE) + if match: + return match[1] + for file in files: + if file["filename"].endswith("/CHANGELOG.md"): + match = re.search(r"^\+##\s+([0-9][^\s(]*)", file.get("patch", ""), re.MULTILINE) + if match: + return match[1] + return "unknown" + + +def run_pr(run, number): + return next((pr for pr in run.get("pull_requests", []) if pr["number"] == number), None) + + +def active_reviews(client): + runs = {} + issues = [] + try: + for run in client.pages( + f"repos/{client.repo}/actions/workflows/{WORKFLOW}/runs?per_page=100", + "workflow_runs", + ): + if run["status"] != "completed": + runs[run["id"]] = run + except CollectionError as error: + issues.append(f"Review activity unknown: {error}") + for run in runs.values(): + if not run.get("pull_requests"): + issues.append(f"Review activity unknown: active run has no PR association: {run['html_url']}") + return list(runs.values()), issues + + +def bot_review(comments): + candidates = [] + for comment in comments: + body = comment.get("body") or "" + if ( + (comment.get("user") or {}).get("login", "").casefold() == "github-actions[bot]" + and "## Management SDK PR review" in body + and ("" in body or "workflow_id: mgmt-sdk-pr-review," in body) + ): + candidates.append(comment) + return max(candidates, key=lambda item: (timestamp(item["created_at"]), item["id"]), default=None) + + +def review_freshness(client, workflow_id, pr, comment): + if comment is None: + return "missing" + match = re.search( + rf"https://github\.com/{re.escape(client.repo)}/actions/runs/([0-9]+)\b", comment["body"], re.IGNORECASE + ) + if not match: + raise CollectionError(f"Automated review freshness unknown: no linked workflow run in {comment['html_url']}") + run = client.cached(f"repos/{client.repo}/actions/runs/{match[1]}") + association = run_pr(run, pr["number"]) + if run["workflow_id"] != workflow_id or association is None or not association.get("head", {}).get("sha"): + raise CollectionError(f"Automated review freshness unknown: cannot verify linked run {run['html_url']}") + return "current" if association["head"]["sha"] == pr["head"]["sha"] else "outdated" + + +def collect_pr(client, summary): + number = summary["number"] + endpoint = f"repos/{client.repo}/pulls/{number}" + pr = client.get(endpoint) + if pr["state"] != "open" or pr["draft"]: + return None + if summary.get("updatedAt") != pr["updated_at"]: + raise CollectionError(f"PR #{number} changed since initial listing; rerun before acting.") + match = PACKAGE.match(pr["title"]) + if not match: + return None + name = match[1] + files = client.pages(f"{endpoint}/files?per_page=100") + package_files = [file for file in files if name in file["filename"].split("/")] + if len(files) < pr["changed_files"]: + raise CollectionError(f"Changed-file listing incomplete for PR #{number}.") + comments = client.pages(f"repos/{client.repo}/issues/{number}/comments?per_page=100") + reviews = client.pages(f"{endpoint}/reviews?per_page=100") + issues = [] + try: + maintainers, owners, skipped_team = client.owners(pr["base"]["sha"], files, name) + except CollectionError as error: + maintainers, owners, skipped_team = None, [], False + issues.append(f"Approval verification incomplete: {error}") + approval = effective_approval(reviews, maintainers) if maintainers is not None else None + if ( + skipped_team + and approval is None + and summary.get("reviewDecision") + not in { + "APPROVED", + "REVIEW_REQUIRED", + "CHANGES_REQUESTED", + } + ): + issues.append("GitHub review decision unavailable; cannot verify team-only approval.") + followups = [] + if approval: + inline = client.pages(f"{endpoint}/comments?per_page=100") + author = pr["user"]["login"].casefold() + for comment in comments + inline + reviews: + created = comment.get("submitted_at") if "state" in comment else comment.get("created_at") + if ( + created + and (comment.get("user") or {}).get("login", "").casefold() == author + and (comment.get("body") or "").strip() + and timestamp(created) > timestamp(approval["submitted_at"]) + ): + followups.append(comment) + version = sdk_version(package_files) + if version == "unknown": + issues.append("SDK version unknown: no added version metadata in the package diff.") + current = client.get(endpoint) + changed = ( + any(current[key]["sha"] != pr[key]["sha"] for key in ("head", "base")) + or current["updated_at"] != pr["updated_at"] + ) + if current["state"] != "open" or current["draft"]: + return None + if changed: + raise CollectionError(f"PR #{number} changed during collection; rerun before acting.") + return { + "pr": pr, + "name": name, + "version": version, + "approval": approval, + "owners": owners, + "ownership_known": maintainers is not None and not skipped_team, + "review_decision": summary.get("reviewDecision"), + "bot_review": bot_review(comments), + "followups": followups, + "issues": issues, + "queues": {}, + } + + +def classify(row, client, workflow_id, runs, activity_issues): + pr = row["pr"] + queues = row["queues"] + active = [run for run in runs if run_pr(run, pr["number"])] + if active: + links = [] + for run in active: + head = run_pr(run, pr["number"]).get("head", {}).get("sha") + revision = "; older/different head" if head and head != pr["head"]["sha"] else "" + links.append(f"[{run['status']}{revision}]({run['html_url']})") + queues[2] = f"Review active: {', '.join(links)}. Wait; do not request a duplicate." + elif activity_issues: + row["issues"].append("Review activity unknown; inspect workflow runs before changing any review label.") + else: + try: + freshness = review_freshness(client, workflow_id, pr, row["bot_review"]) + except CollectionError as error: + row["issues"].append(str(error)) + else: + if freshness in {"missing", "outdated"}: + labeled = any(label["name"].casefold() == "mgmt-review-needed" for label in pr["labels"]) + action = "Remove and reapply" if labeled else "Add" + queues[1] = ( + f"Bot review {freshness}; no active review detected. " + f"{action} `mgmt-review-needed` after confirming activity at the run link." + ) + approval = row["approval"] + if row["ownership_known"] and approval is None: + queues[3] = f"No effective maintainer approval. Ask {', '.join(row['owners'])} to review." + elif row["review_decision"] in {"REVIEW_REQUIRED", "CHANGES_REQUESTED"}: + queues[3] = f"GitHub review decision: {row['review_decision']}. Inspect outstanding review requirements." + if approval: + if not approval.get("commit_id"): + row["issues"].append("Approved commit unknown; cannot verify changes after approval.") + elif approval["commit_id"] != pr["head"]["sha"]: + queues[4] = ( + f"Head changed since [@{approval['user']['login']}'s approval]({approval['html_url']}). " + "Inspect the changes and current review requirements; re-approve only if required." + ) + if row["followups"]: + links = ", ".join(f"[comment]({comment['html_url']})" for comment in row["followups"]) + queues[5] = ( + f"Author follow-up after approval: {links}. Read and respond or route; do not duplicate approval." + ) + + +def render_report(repo, rows, issues): + rows = sorted(rows, key=lambda row: timestamp(row["pr"]["updated_at"]), reverse=True) + warnings = list(issues) + for row in rows: + warnings.extend(f"[{repo}#{row['pr']['number']}]({row['pr']['html_url']}): {issue}" for issue in row["issues"]) + runs_url = f"https://github.com/{repo}/actions/workflows/{WORKFLOW}" + output = [ + f"Scanned {len(rows)} open, non-draft management AutoPRs" + ("; verification incomplete." if warnings else "."), + "", + f"[Management review runs]({runs_url}). Point-in-time report; confirm activity before changing labels.", + ] + for index, title in enumerate(SECTIONS, 1): + entries = [row for row in rows if index in row["queues"]] + if entries: + output.extend(["", f"## {index}. {title}", ""]) + for row in entries: + pr = row["pr"] + output.append( + f"- [{repo}#{pr['number']}]({pr['html_url']}) - `{row['name']}` `{row['version']}`" + f" - {row['queues'][index]}" + ) + if warnings: + output.extend(["", "## Verification warnings", ""]) + output.extend(f"- {' '.join(issue.split())}" for issue in dict.fromkeys(warnings)) + elif not any(row["queues"] for row in rows): + output.extend(["", "No PRs need action in these queues. Release readiness is not assessed."]) + return "\n".join(output), bool(warnings) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="Azure/azure-sdk-for-python") + parser.add_argument("--package", help="Exact package name") + parser.add_argument("--version", help="Exact SDK version (first releases included by default)") + parser.add_argument("--limit", type=int, default=500, help="Maximum initial PR listing size") + args = parser.parse_args(argv) + if args.limit < 1: + parser.error("--limit must be positive") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repo): + parser.error("--repo must be owner/name") + if args.package and not re.fullmatch(r"azure-mgmt-[A-Za-z0-9-]+", args.package): + parser.error("--package must be an azure-mgmt-* package name") + client = GitHub(args.repo) + issues = [] + try: + prs = gh( + "pr", + "list", + "--repo", + args.repo, + "--state", + "open", + "--search", + '"[AutoPR azure-mgmt-" in:title draft:false sort:updated-desc', + "--limit", + str(args.limit), + "--json", + "number,title,isDraft,reviewDecision,updatedAt", + ) + if len(prs) >= args.limit: + issues.append(f"PR listing reached --limit {args.limit}; increase it for a complete scan.") + selected = [] + for pr in prs: + match = PACKAGE.match(pr["title"]) + if match and not pr["isDraft"] and (not args.package or match[1] == args.package): + selected.append(pr) + rows = [] + + def collect(summary): + try: + return collect_pr(client, summary), None + except CollectionError as error: + return None, f"PR #{summary['number']}: collection incomplete: {error}" + + with ThreadPoolExecutor(max_workers=4) as pool: + for row, error in pool.map(collect, selected): + if error: + issues.append(error) + elif row: + if not args.version or row["version"] == args.version: + rows.append(row) + elif row["version"] == "unknown": + issues.append(f"PR #{row['pr']['number']}: unknown version; cannot apply --version filter.") + # Refresh activity after collecting PR evidence, close to report generation. + runs, activity_issues = active_reviews(client) if rows else ([], []) + issues.extend(activity_issues) + try: + workflow_id = client.cached(f"repos/{args.repo}/actions/workflows/{WORKFLOW}")["id"] if rows else None + except CollectionError as error: + workflow_id = None + issues.append(f"Automated review workflow identity unknown: {error}") + for row in rows: + classify(row, client, workflow_id, runs, activity_issues) + report, incomplete = render_report(args.repo, rows, issues) + print(f"Observed at {datetime.now(timezone.utc).isoformat()}.") + print(report) + return 2 if incomplete else 0 + except CollectionError as error: + print(f"Scan failed; no complete PR report is available: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/azsdk-python-mgmt-pr-triage/tests/test_triage_prs.py b/.github/skills/azsdk-python-mgmt-pr-triage/tests/test_triage_prs.py new file mode 100644 index 000000000000..f6a932dda5fb --- /dev/null +++ b/.github/skills/azsdk-python-mgmt-pr-triage/tests/test_triage_prs.py @@ -0,0 +1,600 @@ +"""Offline fixtures for release-rotation queues and conservative activity checks.""" + +import base64 +from copy import deepcopy +import importlib.util +import io +from pathlib import Path +import unittest +from unittest.mock import patch + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "triage_prs.py" +SPEC = importlib.util.spec_from_file_location("triage_prs", SCRIPT) +triage = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(triage) + +REPO = "Azure/azure-sdk-for-python" +PR_URL = f"https://github.com/{REPO}/pull/42" +RUN_URL = f"https://github.com/{REPO}/actions/runs/100" +HEAD = "a" * 40 +OLD = "b" * 40 +WHEN = "2026-10-26T09:00:00Z" +LATER = "2026-10-26T10:00:00Z" +PR = { + "number": 42, + "title": "[AutoPR azure-mgmt-example]-generated", + "state": "open", + "draft": False, + "html_url": PR_URL, + "updated_at": WHEN, + "head": {"sha": HEAD}, + "base": {"sha": OLD}, + "changed_files": 1, + "user": {"login": "author"}, + "labels": [], +} +SUMMARY = {"number": 42, "title": PR["title"], "isDraft": False, "updatedAt": WHEN} +FILE = { + "filename": "sdk/example/azure-mgmt-example/azure/mgmt/example/_version.py", + "status": "modified", + "patch": '+VERSION = "1.0.0b1"', +} + + +def review(state="APPROVED", login="maintainer", when=WHEN, identifier=1, commit=HEAD): + return { + "id": identifier, + "user": {"login": login}, + "state": state, + "submitted_at": when, + "commit_id": commit, + "html_url": PR_URL + f"#pullrequestreview-{identifier}", + "body": "", + } + + +def run(status="completed", head=HEAD, associated=True): + return { + "id": 100, + "workflow_id": 9, + "status": status, + "html_url": RUN_URL, + # Deliberately different from the associated PR head. + "head_sha": OLD, + "pull_requests": [{"number": 42, "head": {"sha": head}}] if associated else [], + } + + +def comment(body="Please check", login="author", when=LATER, identifier=2): + return { + "id": identifier, + "body": body, + "user": {"login": login}, + "created_at": when, + "html_url": PR_URL + f"#issuecomment-{identifier}", + } + + +def bot(body=None): + return comment( + body + or ( + "## Management SDK PR review\nPartial result; human review required.\n" + f"" + ), + "github-actions[bot]", + ) + + +def row(approval=None, bot_comment=None): + return { + "pr": deepcopy(PR), + "name": "azure-mgmt-example", + "version": "1.0.0b1", + "approval": approval, + "owners": ["@maintainer"], + "ownership_known": True, + "review_decision": "APPROVED" if approval else "REVIEW_REQUIRED", + "bot_review": bot_comment, + "followups": [], + "issues": [], + "queues": {}, + } + + +class FakeGitHub: + repo = REPO + + def __init__(self): + self.details = deepcopy(PR) + self.reviews = [review()] + self.comments = [bot()] + self.inline = [] + self.files = [deepcopy(FILE)] + self.linked_run = run() + self.workflow_runs = [] + self.ownership_error = False + self.skipped_team = False + self.activity_error = False + self.last_details = None + self.get_count = 0 + + def get(self, endpoint): + self.get_count += 1 + if self.get_count > 1 and self.last_details is not None: + return deepcopy(self.last_details) + return deepcopy(self.details) + + def cached(self, endpoint): + if "/actions/runs/" in endpoint: + return deepcopy(self.linked_run) + return {"id": 9} + + def pages(self, endpoint, key=None): + if "/actions/workflows/" in endpoint: + if self.activity_error: + raise triage.CollectionError("HTTP 403") + return deepcopy(self.workflow_runs) + if "/reviews?" in endpoint: + return deepcopy(self.reviews) + if "/issues/" in endpoint: + return deepcopy(self.comments) + if "/comments?" in endpoint: + return deepcopy(self.inline) + return deepcopy(self.files) + + def owners(self, base_sha, files, package): + if self.ownership_error: + raise triage.CollectionError("Team membership unreadable") + return {"maintainer"}, ["@maintainer"], self.skipped_team + + +class ApprovalTests(unittest.TestCase): + def test_comment_does_not_invalidate_approval(self): + reviews = [review(), review("COMMENTED", when=LATER, identifier=2)] + self.assertEqual(triage.effective_approval(reviews, {"maintainer"})["id"], 1) + + def test_changes_requested_or_dismissal_invalidates_approval(self): + for state in ("CHANGES_REQUESTED", "DISMISSED"): + with self.subTest(state=state): + self.assertIsNone( + triage.effective_approval([review(), review(state, when=LATER, identifier=2)], {"maintainer"}) + ) + + def test_later_approval_and_login_case(self): + reviews = [review("CHANGES_REQUESTED"), review(login="Maintainer", when=LATER, identifier=2)] + self.assertEqual(triage.effective_approval(reviews, {"maintainer"})["id"], 2) + self.assertIsNone(triage.effective_approval(reviews, {"other"})) + + def test_pending_review_ignored(self): + pending = review("PENDING", identifier=2) + pending["submitted_at"] = None + self.assertEqual(triage.effective_approval([pending, review()], {"maintainer"})["id"], 1) + + +class OwnershipTests(unittest.TestCase): + def setUp(self): + self.client = triage.GitHub(REPO) + + def test_last_rule_and_nested_double_star(self): + text = "/sdk/ @general\n/**/*mgmt*/ @maintainer\n/sdk/example/azure-mgmt-example/private/ @special\n" + with patch.object(self.client, "load_rules", return_value=triage.parse_codeowners(text)) as load: + owners = self.client.owners(OLD, [FILE], "azure-mgmt-example") + self.assertEqual(owners, ({"maintainer"}, ["@maintainer"], False)) + self.client.owners(OLD, [FILE], "azure-mgmt-example") + load.assert_called_once() + special = dict(FILE, filename="sdk/example/azure-mgmt-example/private/client.py") + self.assertEqual(self.client.owners(OLD, [special], "azure-mgmt-example")[0], {"special"}) + + def test_rename_uses_previous_path_and_unrelated_files_ignored(self): + rules = triage.parse_codeowners("/sdk/ @general\n/sdk/example/azure-mgmt-example/old.py @old") + renamed = dict( + FILE, + status="renamed", + filename="sdk/example/azure-mgmt-example/new.py", + previous_filename="sdk/example/azure-mgmt-example/old.py", + ) + with patch.object(self.client, "load_rules", return_value=rules): + self.assertEqual( + self.client.owners(OLD, [renamed, {"filename": "eng/file.py"}], "azure-mgmt-example")[0], {"old"} + ) + + def test_missing_owner_is_unknown(self): + with patch.object(self.client, "load_rules", return_value=triage.parse_codeowners("/elsewhere/ @other")): + with self.assertRaises(triage.CollectionError): + self.client.owners(OLD, [FILE], "azure-mgmt-example") + + def test_team_members_cached_and_casefolded(self): + rules = triage.parse_codeowners("/sdk/ @Azure/owners") + with patch.object(self.client, "load_rules", return_value=rules), patch.object( + self.client, "pages", return_value=[{"login": "Maintainer"}] + ) as pages: + self.assertEqual(self.client.owners(OLD, [FILE], "azure-mgmt-example")[0], {"maintainer"}) + self.client.owners(OLD, [FILE], "azure-mgmt-example") + pages.assert_called_once() + + def test_codeowners_file_precedence(self): + content = base64.b64encode(b"/sdk/ @maintainer").decode() + with patch.object(self.client, "cached", return_value={"encoding": "base64", "content": content}) as cached: + self.client.load_rules(OLD) + self.assertIn("/contents/.github/CODEOWNERS?", cached.call_args.args[0]) + cached.assert_called_once() + + def test_expected_team_403_skipped_and_cached_without_losing_named_owners(self): + rules = triage.parse_codeowners("/sdk/ @Azure/azure-arm-sdk-reviewers @maintainer") + with patch.object(self.client, "load_rules", return_value=rules), patch.object( + self.client, + "pages", + side_effect=triage.CollectionError("Resource not accessible by integration (HTTP 403)"), + ) as pages: + for _ in range(2): + maintainers, owners, skipped = self.client.owners(OLD, [FILE], "azure-mgmt-example") + self.assertEqual(maintainers, {"maintainer"}) + self.assertIn("@Azure/azure-arm-sdk-reviewers", owners) + self.assertTrue(skipped) + pages.assert_called_once() + + def test_other_team_403_or_other_error_still_reported(self): + for owner, error in ( + ("@Azure/other-team", "HTTP 403"), + ("@Azure/azure-arm-sdk-reviewers", "HTTP 401"), + ("@Azure/azure-arm-sdk-reviewers", "HTTP 500"), + ): + with self.subTest(owner=owner, error=error): + client = triage.GitHub(REPO) + rules = triage.parse_codeowners(f"/sdk/ {owner} @maintainer") + with patch.object(client, "load_rules", return_value=rules), patch.object( + client, "pages", side_effect=triage.CollectionError(error) + ): + with self.assertRaises(triage.CollectionError): + client.owners(OLD, [FILE], "azure-mgmt-example") + + def test_empty_override_does_not_fall_back(self): + rules = triage.parse_codeowners("/sdk/ @maintainer\n/sdk/example/azure-mgmt-example/\n") + with patch.object(self.client, "load_rules", return_value=rules): + with self.assertRaises(triage.CollectionError): + self.client.owners(OLD, [FILE], "azure-mgmt-example") + + +class ActivityTests(unittest.TestCase): + def test_all_active_states_including_old_head(self): + for status in triage.ACTIVE_STATUSES: + with self.subTest(status=status): + item = row() + triage.classify(item, FakeGitHub(), 9, [run(status, OLD)], []) + self.assertIn(2, item["queues"]) + self.assertNotIn(1, item["queues"]) + self.assertIn("older/different head", item["queues"][2]) + + def test_unassociated_run_prevents_trigger_recommendation(self): + client = FakeGitHub() + client.workflow_runs = [run("queued", associated=False)] + runs, issues = triage.active_reviews(client) + item = row() + triage.classify(item, client, 9, runs, issues) + self.assertNotIn(1, item["queues"]) + self.assertIn("Review activity unknown", " ".join(item["issues"])) + self.assertIn(RUN_URL, " ".join(issues)) + + def test_activity_access_failure_is_unknown(self): + client = FakeGitHub() + client.activity_error = True + runs, issues = triage.active_reviews(client) + item = row() + triage.classify(item, client, 9, runs, issues) + self.assertNotIn(1, item["queues"]) + self.assertTrue(issues) + + def test_other_pr_active_run_does_not_block(self): + other = run("queued") + other["pull_requests"][0]["number"] = 43 + item = row() + triage.classify(item, FakeGitHub(), 9, [other], []) + self.assertIn(1, item["queues"]) + self.assertNotIn(2, item["queues"]) + + def test_label_presence_only_changes_trigger_instruction(self): + for labeled in (False, True): + with self.subTest(labeled=labeled): + item = row() + item["pr"]["labels"] = [{"name": "mgmt-review-needed"}] if labeled else [] + triage.classify(item, FakeGitHub(), 9, [], []) + self.assertIn("Remove and reapply" if labeled else "Add", item["queues"][1]) + + def test_status_independent_request_filters_completed_runs(self): + client = FakeGitHub() + client.workflow_runs = [run("in_progress"), dict(run(), id=101)] + with patch.object(client, "pages", wraps=client.pages) as pages: + runs, issues = triage.active_reviews(client) + pages.assert_called_once_with( + f"repos/{REPO}/actions/workflows/{triage.WORKFLOW}/runs?per_page=100", "workflow_runs" + ) + self.assertEqual(len(runs), 1) + self.assertFalse(issues) + + def test_waiting_to_in_progress_transition_cannot_hide_active_run(self): + client = FakeGitHub() + active = run("waiting") + + def transition(endpoint, key=None): + if "?status=waiting&" in endpoint or "?status=" not in endpoint: + active["status"] = "in_progress" + if "?status=" not in endpoint or f"status={active['status']}&" in endpoint: + return [deepcopy(active)] + return [] + + with patch.object(client, "pages", side_effect=transition): + runs, issues = triage.active_reviews(client) + item = row() + triage.classify(item, client, 9, runs, issues) + self.assertFalse(issues) + self.assertEqual(runs[0]["status"], "in_progress") + self.assertIn(2, item["queues"]) + self.assertNotIn(1, item["queues"]) + + def test_active_run_on_later_page_is_found(self): + client = triage.GitHub(REPO) + pages = [ + {"total_count": 2, "workflow_runs": [run()]}, + {"total_count": 2, "workflow_runs": [run("queued")]}, + ] + with patch.object(triage, "gh", return_value=pages): + runs, issues = triage.active_reviews(client) + self.assertFalse(issues) + self.assertEqual([item["status"] for item in runs], ["queued"]) + + +class FreshnessTests(unittest.TestCase): + def test_current_comment_with_different_run_head_sha(self): + self.assertEqual(triage.review_freshness(FakeGitHub(), 9, PR, bot()), "current") + item = row(review(), bot()) + triage.classify(item, FakeGitHub(), 9, [], []) + self.assertFalse(item["queues"]) + + def test_older_linked_pr_head_is_outdated(self): + client = FakeGitHub() + client.linked_run = run(head=OLD) + item = row(bot_comment=bot()) + triage.classify(item, client, 9, [], []) + self.assertIn("outdated", item["queues"][1]) + + def test_unverifiable_link_never_recommends_trigger(self): + for linked in (dict(run(), workflow_id=8), run(associated=False), run(head=None)): + with self.subTest(linked=linked): + client = FakeGitHub() + client.linked_run = linked + item = row(bot_comment=bot()) + triage.classify(item, client, 9, [], []) + self.assertNotIn(1, item["queues"]) + self.assertIn("freshness unknown", item["issues"][0]) + + def test_missing_run_link_is_unknown(self): + with self.assertRaises(triage.CollectionError): + triage.review_freshness(FakeGitHub(), 9, PR, bot("No linked run")) + + def test_heading_alone_does_not_identify_automated_review(self): + self.assertIsNone(triage.bot_review([comment("## Management SDK PR review")])) + fake = bot() + fake["user"]["login"] = "someone" + self.assertIsNone(triage.bot_review([fake])) + self.assertEqual(triage.bot_review([bot()])["id"], 2) + + def test_old_workflow_marker_supported(self): + item = bot("\n## Management SDK PR review") + self.assertEqual(triage.bot_review([item]), item) + + +class CollectionTests(unittest.TestCase): + def test_skipped_team_preserves_named_approval_changes_and_followups(self): + client = FakeGitHub() + client.skipped_team = True + client.reviews = [review(commit=OLD)] + client.comments.append(comment()) + item = triage.collect_pr(client, dict(SUMMARY, reviewDecision="APPROVED")) + triage.classify(item, client, 9, [], []) + self.assertIsNotNone(item["approval"]) + self.assertFalse(item["issues"]) + self.assertNotIn(3, item["queues"]) + self.assertIn(4, item["queues"]) + self.assertIn(5, item["queues"]) + + def test_skipped_team_uses_github_decision_without_missing_approval_claim(self): + for decision in ("APPROVED", "REVIEW_REQUIRED", "CHANGES_REQUESTED"): + with self.subTest(decision=decision): + client = FakeGitHub() + client.skipped_team = True + client.reviews = [review(login="team-only-reviewer")] + item = triage.collect_pr(client, dict(SUMMARY, reviewDecision=decision)) + triage.classify(item, client, 9, [], []) + self.assertFalse(item["issues"]) + if decision == "APPROVED": + self.assertNotIn(3, item["queues"]) + else: + self.assertIn(f"GitHub review decision: {decision}", item["queues"][3]) + + def test_skipped_team_without_github_decision_is_still_unverified(self): + client = FakeGitHub() + client.skipped_team = True + client.reviews = [] + item = triage.collect_pr(client, SUMMARY) + self.assertIn("GitHub review decision unavailable", item["issues"][0]) + + def test_first_beta_and_author_followups_in_all_comment_channels(self): + client = FakeGitHub() + client.comments.append(comment()) + client.inline = [comment(identifier=3)] + author_review = review("COMMENTED", "author", LATER, 4) + author_review["body"] = "Ready now?" + client.reviews.append(author_review) + item = triage.collect_pr(client, dict(SUMMARY, reviewDecision="APPROVED")) + self.assertEqual(item["version"], "1.0.0b1") + self.assertEqual(len(item["followups"]), 3) + triage.classify(item, client, 9, [], []) + self.assertIn(5, item["queues"]) + self.assertNotIn(4, item["queues"]) + + def test_comments_before_approval_or_by_other_people_not_followups(self): + client = FakeGitHub() + client.comments += [comment(when=WHEN), comment(login="other")] + item = triage.collect_pr(client, SUMMARY) + self.assertFalse(item["followups"]) + + def test_old_commit_timestamps_cannot_hide_changed_head(self): + item = row(review(commit=OLD), bot()) + triage.classify(item, FakeGitHub(), 9, [], []) + self.assertIn(4, item["queues"]) + + def test_missing_commit_is_verification_warning(self): + item = row(review(commit=None), bot()) + triage.classify(item, FakeGitHub(), 9, [], []) + self.assertTrue(item["issues"]) + self.assertNotIn(4, item["queues"]) + + def test_ownership_failure_is_not_missing_approval(self): + client = FakeGitHub() + client.ownership_error = True + item = triage.collect_pr(client, SUMMARY) + triage.classify(item, client, 9, [], []) + self.assertNotIn(3, item["queues"]) + self.assertIn("Approval verification incomplete", item["issues"][0]) + + def test_github_review_requirement_even_with_approval(self): + item = row(review(), bot()) + item["review_decision"] = "CHANGES_REQUESTED" + triage.classify(item, FakeGitHub(), 9, [], []) + self.assertIn(3, item["queues"]) + + def test_changed_pr_requires_rescan(self): + client = FakeGitHub() + client.last_details = deepcopy(PR) + client.last_details["head"]["sha"] = OLD + with self.assertRaises(triage.CollectionError): + triage.collect_pr(client, SUMMARY) + + def test_changes_requested_since_listing_rejects_stale_approval(self): + client = FakeGitHub() + client.details["updated_at"] = LATER + client.reviews = [review(), review("CHANGES_REQUESTED", "second", LATER, 2)] + with self.assertRaisesRegex(triage.CollectionError, "changed since initial listing"): + triage.collect_pr(client, dict(SUMMARY, reviewDecision="APPROVED")) + self.assertEqual(client.get_count, 1) + + def test_missing_listing_timestamp_cannot_bypass_snapshot_guard(self): + with self.assertRaisesRegex(triage.CollectionError, "changed since initial listing"): + triage.collect_pr(FakeGitHub(), {"number": 42, "reviewDecision": "APPROVED"}) + + def test_timestamp_change_during_collection_rejects_snapshot(self): + client = FakeGitHub() + client.last_details = dict(PR, updated_at=LATER) + with self.assertRaisesRegex(triage.CollectionError, "changed during collection"): + triage.collect_pr(client, SUMMARY) + + def test_newly_closed_or_draft_pr_skipped(self): + for changes in ({"state": "closed"}, {"draft": True}): + client = FakeGitHub() + client.details.update(changes) + self.assertIsNone(triage.collect_pr(client, SUMMARY)) + + def test_truncated_files_not_treated_as_complete(self): + client = FakeGitHub() + client.details["changed_files"] = 3001 + with self.assertRaises(triage.CollectionError): + triage.collect_pr(client, SUMMARY) + + def test_version_changelog_fallback(self): + file = dict(FILE, filename="sdk/example/azure-mgmt-example/CHANGELOG.md", patch="+## 2.0.0 (Unreleased)") + self.assertEqual(triage.sdk_version([file]), "2.0.0") + + +class ReportAndCliTests(unittest.TestCase): + def test_partial_scan_never_claims_no_action(self): + report, incomplete = triage.render_report(REPO, [], ["HTTP 403"]) + self.assertTrue(incomplete) + self.assertNotIn("No PRs need action", report) + self.assertIn("verification incomplete", report) + + def test_queue_numbers_preserved_and_rows_sorted(self): + first, second = row(), row() + second["pr"]["number"] = 43 + second["pr"]["html_url"] = PR_URL.replace("42", "43") + second["pr"]["updated_at"] = LATER + first["queues"][4] = second["queues"][4] = "Inspect changes." + report, incomplete = triage.render_report(REPO, [first, second], []) + self.assertFalse(incomplete) + self.assertIn("## 4. Changes after approval", report) + self.assertNotIn("## 1.", report) + self.assertLess(report.index(f"{REPO}#43"), report.index(f"{REPO}#42")) + + def test_paginated_lists_and_incomplete_objects(self): + client = triage.GitHub(REPO) + with patch.object(triage, "gh", return_value=[[{"id": 1}], [{"id": 2}]]): + self.assertEqual(len(client.pages("endpoint")), 2) + with patch.object(triage, "gh", return_value=[{"total_count": 2, "workflow_runs": [run()]}]): + with self.assertRaises(triage.CollectionError): + client.pages("endpoint", "workflow_runs") + + def test_cli_default_includes_first_beta(self): + listing = [dict(SUMMARY, reviewDecision="APPROVED")] + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "GitHub", return_value=FakeGitHub() + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main([]), 0) + self.assertIn("Scanned 1", output.getvalue()) + self.assertIn("No PRs need action", output.getvalue()) + + def test_cli_stale_listing_never_claims_no_action(self): + listing = [dict(SUMMARY, reviewDecision="APPROVED")] + client = FakeGitHub() + client.details["updated_at"] = LATER + client.reviews = [review(), review("CHANGES_REQUESTED", "second", LATER, 2)] + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "GitHub", return_value=client + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main([]), 2) + self.assertIn("changed since initial listing", output.getvalue()) + self.assertNotIn("No PRs need action", output.getvalue()) + + def test_cli_limit_marks_partial(self): + listing = [dict(SUMMARY)] + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "GitHub", return_value=FakeGitHub() + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main(["--limit", "1"]), 2) + self.assertIn("reached --limit", output.getvalue()) + self.assertNotIn("No PRs need action", output.getvalue()) + + def test_cli_exact_package_filter_and_drafts(self): + listing = [ + dict(SUMMARY), + dict(SUMMARY, number=43, title="[AutoPR azure-mgmt-other]-generated"), + dict(SUMMARY, number=44, isDraft=True), + ] + client = FakeGitHub() + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "GitHub", return_value=client + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main(["--package", "azure-mgmt-example"]), 0) + self.assertIn("Scanned 1", output.getvalue()) + + def test_cli_unknown_version_cannot_silently_match_filter(self): + listing = [dict(SUMMARY)] + client = FakeGitHub() + client.files[0]["patch"] = "" + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "GitHub", return_value=client + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main(["--version", "1.0.0b1"]), 2) + self.assertIn("cannot apply --version", output.getvalue()) + + def test_cli_collection_failure_reports_partial(self): + listing = [dict(SUMMARY)] + with patch.object(triage, "gh", return_value=listing), patch.object( + triage, "collect_pr", side_effect=triage.CollectionError("HTTP 403") + ), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(triage.main([]), 2) + self.assertIn("collection incomplete", output.getvalue()) + self.assertNotIn("No PRs need action", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/azure-sdk-tools.yml b/.github/workflows/azure-sdk-tools.yml index 0b2b309eeeb7..814f466e3f4e 100644 --- a/.github/workflows/azure-sdk-tools.yml +++ b/.github/workflows/azure-sdk-tools.yml @@ -6,6 +6,7 @@ on: branches: [ main ] paths: - "eng/tools/azure-sdk-tools/**" + - "eng/scripts/run_with_1es_proxy_trust.py" - ".github/workflows/azure-sdk-tools.yml" jobs: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index da071db7755d..47834f0d3e8c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.10' + python-version: '3.11' cache: 'pip' - name: Create and activate virtual environment @@ -33,6 +33,27 @@ jobs: python -m pip install --upgrade pip python -m pip install -r eng/ci_tools.txt + - name: Detect azure-ai-projects branch + id: branch_check + # Scopes the azure-ai-projects dev dependency pre-install below to sessions whose branch + # name identifies them as azure-ai-projects work (e.g. + # "copilot/azure-ai-projects-regenerate-", the actual naming GitHub's issue + # assignment uses for this package's regeneration workflow), so unrelated cloud agent + # sessions in this repo don't pay for a package-specific install. The Node.js, tsp-client, + # and TypeSpec Python emitter steps are deliberately not gated: every package regenerated + # through tsp-client needs them. A plain branch-name check is used instead of git ancestry + # because actions/checkout's default shallow clone (fetch-depth: 1) does not reliably + # support merge-base ancestry checks. + run: | + BRANCH="$(git rev-parse --abbrev-ref HEAD)" + if [[ "${BRANCH,,}" == *azure-ai-projects* ]]; then + echo "On branch '$BRANCH' (matches azure-ai-projects); pre-installing its dev dependencies." + echo "azure_ai_projects=true" >> "$GITHUB_OUTPUT" + else + echo "On branch '$BRANCH' (no azure-ai-projects match); skipping its dev dependency pre-install." + echo "azure_ai_projects=false" >> "$GITHUB_OUTPUT" + fi + - name: Set up Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: @@ -45,6 +66,41 @@ jobs: # session. run: npm ci --prefix eng/common/tsp-client + - name: Pre-install TypeSpec Python emitter's Python dependencies + # `tsp-client update` (any package, not just azure-ai-projects) shells out to the + # TypeSpec Python emitter (`@azure-tools/typespec-python`), which creates its own isolated + # venv and pip-installs the `pygen` generator bundled in `@typespec/http-client-python` + # (pinned in eng/emitter-package.json) plus its pinned requirements (black, docutils, + # Jinja2, PyYAML, tomli, setuptools, ...). A plain "pip install" here would only warm pip's + # HTTP cache, which pip does not consult when the package index is unreachable. Instead, + # download the complete runtime requirement set, derived from the pinned package itself, + # into a fixed wheelhouse and point PIP_FIND_LINKS at it for the rest of the job, + # including the firewalled agent session. The final step verifies an install of the + # generator succeeds with the index disabled. + run: | + WHEELHOUSE="$HOME/.cache/pip-find-links" + WORKDIR="$(mktemp -d)" + mkdir -p "$WHEELHOUSE" + PYGEN_VERSION="$(jq -r '.devDependencies["@typespec/http-client-python"]' eng/emitter-package.json)" + (cd "$WORKDIR" && npm pack "@typespec/http-client-python@${PYGEN_VERSION}" --silent && tar xzf typespec-http-client-python-*.tgz) + PYGEN_DIR="$WORKDIR/package/generator" + python -m pip download -d "$WHEELHOUSE" pip setuptools wheel + python -m pip download -d "$WHEELHOUSE" "$PYGEN_DIR" + python -m venv "$WORKDIR/verify-venv" + PIP_NO_INDEX=1 "$WORKDIR/verify-venv/bin/python" -m pip install --find-links "$WHEELHOUSE" "$PYGEN_DIR" + echo "PIP_FIND_LINKS=$WHEELHOUSE" >> "$GITHUB_ENV" + + - name: Pre-install azure-ai-projects dev dependencies + if: steps.branch_check.outputs.azure_ai_projects == 'true' + run: | + # dev_requirements.txt contains bare local paths (e.g. "../../core/azure-core") that + # pip resolves against the current working directory, not the requirements file's own + # directory. Run it with cwd set to the package directory, in a subshell so the rest + # of this job keeps the repository root as its working directory. + (cd sdk/ai/azure-ai-projects && \ + python -m pip install -r dev_requirements.txt && \ + python -m pip install -e .) + - name: Install azsdk mcp server shell: pwsh run: | diff --git a/.github/workflows/event-processor.yml b/.github/workflows/event-processor.yml index 16370e2edabb..ab576a2a1c5b 100644 --- a/.github/workflows/event-processor.yml +++ b/.github/workflows/event-processor.yml @@ -17,30 +17,24 @@ on: permissions: {} jobs: - # This event requires the Azure CLI to get the LABEL_SERVICE_API_KEY from the vault. - # Because the azure/login step adds time costly pre/post Az CLI commands to any every job - # it's used in, split this into its own job so only the event that needs the Az CLI pays - # the cost. + # Only newly opened issues need Azure login and secrets from Key Vault. event-handler-with-azure: + if: ${{ github.event_name == 'issues' && github.event.action == 'opened' }} permissions: issues: write - pull-requests: write # For OIDC auth id-token: write - contents: read name: Handle ${{ github.event_name }} ${{ github.event.action }} event with azure login runs-on: ubuntu-latest steps: - name: 'Az CLI login' uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 - if: ${{ github.event_name == 'issues' && github.event.action == 'opened' }} with: client-id: e4cb9fd5-0875-4ca1-bc43-56196f5dfb8f tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47 allow-no-subscriptions: true - name: 'Run Azure CLI commands' - if: ${{ github.event_name == 'issues' && github.event.action == 'opened' }} run: | LABEL_SERVICE_API_KEY=$(az keyvault secret show \ --vault-name issue-labeler \ @@ -60,10 +54,6 @@ jobs: echo "::add-mask::$APP_CONFIG_ENDPOINT" echo "APP_CONFIG_ENDPOINT=$APP_CONFIG_ENDPOINT" >> "$GITHUB_ENV" - # To run github-event-processor built from source, for testing purposes, uncomment everything - # in between the Start/End-Build From Source comments and comment everything in between the - # Start/End-Install comments - # Start-Install - name: Install GitHub Event Processor run: > dotnet tool install @@ -72,28 +62,6 @@ jobs: --add-source https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json --global shell: bash - # End-Install - - # Testing checkout of sources from the Azure/azure-sdk-tools repository - # The ref: is the SHA from the pull request in that repository or the - # refs/pull//merge for the latest on any given PR. If the repository - # is a fork eg. /azure-sdk-tools then the repository down below will - # need to point to that fork - # Start-Build - # - name: Checkout tools repo for GitHub Event Processor sources - # uses: actions/checkout@v3 - # with: - # repository: Azure/azure-sdk-tools - # path: azure-sdk-tools - # ref: /merge> or - - # - name: Build and install GitHubEventProcessor from sources - # run: | - # dotnet pack - # dotnet tool install --global --prerelease --add-source ../../../artifacts/packages/Debug Azure.Sdk.Tools.GitHubEventProcessor - # shell: bash - # working-directory: azure-sdk-tools/tools/github-event-processor/Azure.Sdk.Tools.GitHubEventProcessor - # End-Build - name: Process Action Event run: | @@ -112,3 +80,35 @@ jobs: with: name: event path: ${{ github.event_path }} + + event-handler: + if: ${{ !(github.event_name == 'issues' && github.event.action == 'opened') }} + permissions: + issues: write + # PR events can dismiss approvals after untrusted changes. + pull-requests: write + name: Handle ${{ github.event_name }} ${{ github.event.action }} event + runs-on: ubuntu-latest + steps: + - name: Install GitHub Event Processor + run: > + dotnet tool install + Azure.Sdk.Tools.GitHubEventProcessor + --version 1.0.0-dev.20260403.1 + --add-source https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json + --global + shell: bash + + - name: Process Action Event + run: | + github-event-processor ${{ github.event_name }} ${{ github.event_path }} + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Archive github event data + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: event + path: ${{ github.event_path }} diff --git a/.github/workflows/mgmt-sdk-pr-review.lock.yml b/.github/workflows/mgmt-sdk-pr-review.lock.yml index 2453536dde7d..b7bf817a2d91 100644 --- a/.github/workflows/mgmt-sdk-pr-review.lock.yml +++ b/.github/workflows/mgmt-sdk-pr-review.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f78cc0345dd4b133e778887822eaaf26041f0b58f013a543c863a05277408070","body_hash":"586a55277519678cf103b99f434effd87d4b5c4f538a33c5409e374d9b3b1cc4","compiler_version":"v0.88.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f1275436433438dd3afdc7641a4e9560100f2eb3aa1ea548d7b7ed48e20816cf","body_hash":"40c145711e943f5011486a737935c6bd29d4ae3e3c08b9a86e1652376330e985","compiler_version":"v0.88.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.80"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"11d5960a326750d5838078e36cf38b85af677262","version":"11d5960a326750d5838078e36cf38b85af677262"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"d3f86a106a0bac45b974a628896c90dbdf5c8093"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"ea165f8d65b6e75b540449e92b4886f43607fa02","version":"ea165f8d65b6e75b540449e92b4886f43607fa02"},{"repo":"github/gh-aw-actions/setup","sha":"5e508589e03a7757a7e05b26e834292f5445bfb6","version":"v0.88.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.14","digest":"sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14","digest":"sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.14","digest":"sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.18","digest":"sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.18@sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0daa8971fa4732b647150cb6524a6b0804b68d5d24f6f58b5dd1af23bd63fb23"},{"image":"ghcr.io/github/github-mcp-server:v1.11.0","digest":"sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699","pinned_image":"ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_commit","get_file_contents","get_latest_release","get_me","get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","get_release_by_tag","get_tag","list_branches","list_commits","list_pull_requests","list_releases","list_starred_repositories","list_tags","pull_request_read","search_code","search_pull_requests","search_repositories"]},{"name":"mcpscripts","tools":["review"]},{"name":"safeoutputs","tools":["add_comment","missing_data","missing_tool","noop"]}]} # This file was automatically generated by gh-aw (v0.88.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -540,7 +540,7 @@ jobs: env: GH_AW_FILE_ROOT: "${{ runner.temp }}/gh-aw" GH_AW_FILE_CONFIG: "{\"files\":[{\"path\":\"safeoutputs/config.json\",\"content_env\":\"GH_AW_SAFE_OUTPUTS_CONFIG\"}]}" - GH_AW_SAFE_OUTPUTS_CONFIG: "{\"add_comment\":{\"data_enabled\":true,\"data_schema\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"reviewed\",\"not_applicable\"],\"type\":\"string\"},\"packages\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"attribution\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"cause\":{\"enum\":[\"typespec_api\",\"human_review\"],\"type\":\"string\"},\"entry_id\":{\"pattern\":\"^[0-9a-f]{24}$\",\"type\":\"string\"},\"explanation\":{\"maxLength\":12000,\"type\":\"string\"},\"sdk_context\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"cause\",\"entry_id\",\"explanation\",\"sdk_context\",\"sources\"],\"type\":\"object\"},\"type\":\"array\"},\"checks\":{\"additionalProperties\":false,\"properties\":{\"Client name consistency\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"},\"Client signature\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"},\"README snippets\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"}},\"required\":[\"Client name consistency\",\"Client signature\",\"README snippets\"],\"type\":\"object\"},\"findings\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"check\":{\"enum\":[\"Client signature\",\"Client name consistency\",\"README snippets\"],\"type\":\"string\"},\"observation\":{\"maxLength\":12000,\"type\":\"string\"},\"remediation\":{\"maxLength\":12000,\"type\":\"string\"},\"severity\":{\"enum\":[\"Blocking\",\"Warning\",\"Suggestion\"],\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"},\"title\":{\"maxLength\":12000,\"type\":\"string\"}},\"required\":[\"check\",\"observation\",\"remediation\",\"severity\",\"sources\",\"title\"],\"type\":\"object\"},\"type\":\"array\"},\"package\":{\"pattern\":\"^sdk/[^/]+/azure-mgmt-[a-z0-9-]+$\",\"type\":\"string\"}},\"required\":[\"attribution\",\"checks\",\"findings\",\"package\"],\"type\":\"object\"},\"type\":\"array\"},\"preflight\":{\"additionalProperties\":false,\"properties\":{\"attempt\":{\"minimum\":1,\"type\":\"integer\"},\"digest\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"}},\"required\":[\"attempt\",\"digest\"],\"type\":\"object\"},\"registrations\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"package\":{\"maxLength\":12000,\"type\":\"string\"},\"path\":{\"maxLength\":12000,\"type\":\"string\"},\"repository\":{\"maxLength\":12000,\"type\":\"string\"},\"revision\":{\"maxLength\":12000,\"type\":\"string\"},\"sha256\":{\"maxLength\":12000,\"type\":\"string\"}},\"required\":[\"package\",\"path\",\"repository\",\"revision\",\"sha256\"],\"type\":\"object\"},\"type\":\"array\"},\"schema_version\":{\"enum\":[\"2\"],\"type\":\"string\"}},\"required\":[\"outcome\",\"packages\",\"preflight\",\"registrations\",\"schema_version\"],\"type\":\"object\"},\"discussions\":false,\"footer\":false,\"hide_older_comments\":true,\"max\":1,\"target\":\"\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_CONFIG: "{\"add_comment\":{\"data_enabled\":true,\"data_schema\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"reviewed\",\"not_applicable\"],\"type\":\"string\"},\"packages\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"attribution\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"cause\":{\"enum\":[\"typespec_api\",\"human_review\"],\"type\":\"string\"},\"entry_id\":{\"pattern\":\"^[0-9a-f]{24}$\",\"type\":\"string\"},\"explanation\":{\"maxLength\":12000,\"type\":\"string\"},\"sdk_context\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"cause\",\"entry_id\",\"explanation\",\"sdk_context\",\"sources\"],\"type\":\"object\"},\"type\":\"array\"},\"attribution_omitted\":{\"minimum\":0,\"type\":\"integer\"},\"checks\":{\"additionalProperties\":false,\"properties\":{\"Client name consistency\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"},\"Client signature\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"},\"README snippets\":{\"additionalProperties\":false,\"properties\":{\"outcome\":{\"enum\":[\"completed\",\"unverified\",\"not_applicable\"],\"type\":\"string\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"outcome\",\"reason\",\"sources\"],\"type\":\"object\"}},\"required\":[\"Client name consistency\",\"Client signature\",\"README snippets\"],\"type\":\"object\"},\"findings\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"check\":{\"enum\":[\"Client signature\",\"Client name consistency\",\"README snippets\"],\"type\":\"string\"},\"observation\":{\"maxLength\":12000,\"type\":\"string\"},\"remediation\":{\"maxLength\":12000,\"type\":\"string\"},\"severity\":{\"enum\":[\"Blocking\",\"Warning\",\"Suggestion\"],\"type\":\"string\"},\"sources\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"end_line\":{\"minimum\":0,\"type\":\"integer\"},\"reason\":{\"maxLength\":12000,\"type\":\"string\"},\"source_id\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"},\"start_line\":{\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"end_line\",\"reason\",\"source_id\",\"start_line\"],\"type\":\"object\"},\"type\":\"array\"},\"title\":{\"maxLength\":12000,\"type\":\"string\"}},\"required\":[\"check\",\"observation\",\"remediation\",\"severity\",\"sources\",\"title\"],\"type\":\"object\"},\"type\":\"array\"},\"package\":{\"pattern\":\"^sdk/[^/]+/azure-mgmt-[a-z0-9-]+$\",\"type\":\"string\"}},\"required\":[\"attribution\",\"attribution_omitted\",\"checks\",\"findings\",\"package\"],\"type\":\"object\"},\"type\":\"array\"},\"preflight\":{\"additionalProperties\":false,\"properties\":{\"attempt\":{\"minimum\":1,\"type\":\"integer\"},\"digest\":{\"pattern\":\"^[0-9a-f]{64}$\",\"type\":\"string\"}},\"required\":[\"attempt\",\"digest\"],\"type\":\"object\"},\"registrations\":{\"items\":{\"additionalProperties\":false,\"properties\":{\"package\":{\"maxLength\":12000,\"type\":\"string\"},\"path\":{\"maxLength\":12000,\"type\":\"string\"},\"repository\":{\"maxLength\":12000,\"type\":\"string\"},\"revision\":{\"maxLength\":12000,\"type\":\"string\"},\"sha256\":{\"maxLength\":12000,\"type\":\"string\"}},\"required\":[\"package\",\"path\",\"repository\",\"revision\",\"sha256\"],\"type\":\"object\"},\"type\":\"array\"},\"schema_version\":{\"enum\":[\"2\"],\"type\":\"string\"}},\"required\":[\"outcome\",\"packages\",\"preflight\",\"registrations\",\"schema_version\"],\"type\":\"object\"},\"discussions\":false,\"footer\":false,\"hide_older_comments\":true,\"max\":1,\"target\":\"\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}" with: script: | const path = require('path'); @@ -667,6 +667,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -923,6 +927,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -1110,6 +1115,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -1366,6 +1375,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -1553,6 +1563,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -1809,6 +1823,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -1996,6 +2011,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -2252,6 +2271,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -2439,6 +2459,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -2695,6 +2719,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -2882,6 +2907,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -3138,6 +3167,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -3369,6 +3399,10 @@ jobs: }, "type": "array" }, + "attribution_omitted": { + "minimum": 0, + "type": "integer" + }, "checks": { "additionalProperties": false, "properties": { @@ -3625,6 +3659,7 @@ jobs: }, "required": [ "attribution", + "attribution_omitted", "checks", "findings", "package" @@ -5051,6 +5086,7 @@ jobs: cp .github/workflows/scripts/mgmt_sdk_review_contract.py review-snapshot/ cp .github/workflows/scripts/mgmt_sdk_review_evidence.py review-snapshot/ cp .github/workflows/scripts/mgmt_sdk_review_service.py review-snapshot/ + cp .github/workflows/scripts/mgmt_sdk_review_request.jq review-snapshot/ cd review-snapshot python mgmt_sdk_review_context.py python mgmt_sdk_review_contract.py schema > review-schema.json diff --git a/.github/workflows/mgmt-sdk-pr-review.md b/.github/workflows/mgmt-sdk-pr-review.md index d7d40836269f..3ee1b79b9f4e 100644 --- a/.github/workflows/mgmt-sdk-pr-review.md +++ b/.github/workflows/mgmt-sdk-pr-review.md @@ -73,6 +73,7 @@ jobs: cp .github/workflows/scripts/mgmt_sdk_review_contract.py review-snapshot/ cp .github/workflows/scripts/mgmt_sdk_review_evidence.py review-snapshot/ cp .github/workflows/scripts/mgmt_sdk_review_service.py review-snapshot/ + cp .github/workflows/scripts/mgmt_sdk_review_request.jq review-snapshot/ cd review-snapshot python mgmt_sdk_review_context.py python mgmt_sdk_review_contract.py schema > review-schema.json @@ -214,6 +215,9 @@ safe-outputs: - sources type: object type: array + attribution_omitted: + minimum: 0 + type: integer checks: additionalProperties: false properties: @@ -404,6 +408,7 @@ safe-outputs: type: string required: - attribution + - attribution_omitted - checks - findings - package @@ -537,7 +542,12 @@ Read `review-evidence/review-context.json`, especially `mgmtSdkCodeReviewRules`, to the trusted executing workflow revision, not the SDK PR or a remembered policy. Call the `review` tool with `{"operation":"describe"}` for the canonical `draft`, schema, source IDs, required semantic checks and entry IDs. Start from the returned `draft`, not a handwritten reconstruction of the schema. It includes every -package, required check and attribution entry. Empty reasons/explanations in this template are +package and required check, and a bounded prefix of attribution entries. At most eight entries +are selected across packages, round-robin in trusted changelog order. `attribution_omitted` +records the unreviewed suffix count; `describe` supplies the selected entries and total count. +Do not investigate omitted entries or reset their count. The publisher retains the full snapshot +and labels any omitted attribution as partial, requiring human review. +Empty reasons/explanations in this template are unfinished analysis, not permission to publish an unreviewed draft. The shell bridge is `mcpscripts review .` with `{"request":""}` on stdin. Python and curl are NOT agent shell tools. Use the read-only tool, not shell execution. @@ -584,7 +594,7 @@ Do not report passing checks or unrelated pre-existing problems as findings. ## 3. Attribute breaking changes -Produce exactly one attribution row per trusted `entry_id`; do not repeat release headings, +Produce exactly one attribution row per selected `entry_id`, in the returned order; do not repeat release headings, initial-release flags, collection outcomes, entry text or confidence flags. Code derives them. Use `cause: "typespec_api"` only for direct, verified specification evidence connecting the named change to a definition, decorator, versioning annotation or API selection. This renders @@ -610,16 +620,25 @@ only by the trusted routine version/stability checks. ## 4. Preflight, correct, submit once -Write the full schema-version-2 draft to `/tmp/gh-aw/agent/review.json`. If discovery is complete +Write the bounded schema-version-2 draft to `/tmp/gh-aw/agent/review.json`. If discovery is complete with no management packages, the draft is `{"schema_version":"2","outcome":"not_applicable","packages":[]}`. If discovery is incomplete and no checks completed, use the incomplete path below. Check the draft shape before semantic preflight: ```bash -jq '{request: ({operation: "check", draft: .} | tojson)}' /tmp/gh-aw/agent/review.json | mcpscripts review . +jq --arg operation check -f review-evidence/mgmt_sdk_review_request.jq /tmp/gh-aw/agent/review.json > /tmp/gh-aw/agent/request.json +jq '.request | fromjson | .draft' /tmp/gh-aw/agent/request.json > /tmp/gh-aw/agent/bounded-review.json +mcpscripts review . < /tmp/gh-aw/agent/request.json ``` +The trusted jq filter measures the encoded request, including string escaping, against a +9,000-byte budget below the gateway's 10,240-byte limit. It drops only whole trailing attribution +rows and increments `attribution_omitted`; checks, findings and retained evidence are unchanged. +Use `bounded-review.json` for further corrections and preflight. Never shorten explanations, +remove citations or relabel a cause just to fit the transport. If checks/findings alone exceed +the budget, the filter fails explicitly: use the incomplete path instead of sending an oversized request. + A successful `check` validates only shape and never contains a submission. Correct every format error, preserving the returned template's field names: `attribution` belongs in each package, not `attributions` at the top level. Completed checks use `reason: ""`, as do verified citations. @@ -629,7 +648,8 @@ Use real evidence instead of the example ID or range. Put observations/remediati not in missing-evidence reasons. Then call semantic preflight: ```bash -jq '{request: ({operation: "preflight", draft: .} | tojson)}' /tmp/gh-aw/agent/review.json | mcpscripts review . +jq --arg operation preflight -f review-evidence/mgmt_sdk_review_request.jq /tmp/gh-aw/agent/bounded-review.json > /tmp/gh-aw/agent/request.json +mcpscripts review . < /tmp/gh-aw/agent/request.json ``` Read its JSON result (large tool responses give a file path). Inspect every error `code`, `path` @@ -660,7 +680,9 @@ Multi-package reviews use shared evidence references (`E1`, `E2`, etc.) so an id URL is linked only once across checks, findings and attribution. Each use retains its label and any unavailable-line explanation; different revisions or line ranges remain distinct. Single-package comments retain inline links. The same budgets still apply after rendering; -genuinely oversized reviews remain incomplete rather than dropping findings or evidence. +genuinely oversized reviews remain incomplete rather than dropping findings or retained evidence. +Truncated attribution is published only as an explicitly partial review, with the exact omitted +count and a pinned changelog link for human review; it never implies those entries were checked. Publication errors stay incomplete, not successful reviews. ### Explicit incomplete outcome diff --git a/.github/workflows/post-apiview.yml b/.github/workflows/post-apiview.yml index e47fbdacd792..c88b4d5cc5aa 100644 --- a/.github/workflows/post-apiview.yml +++ b/.github/workflows/post-apiview.yml @@ -24,7 +24,7 @@ jobs: contains(github.event.check_run.name, 'SDK Validation') ) steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: "eng/common" diff --git a/.github/workflows/scheduled-event-processor.yml b/.github/workflows/scheduled-event-processor.yml index 659314016110..780366c1f09b 100644 --- a/.github/workflows/scheduled-event-processor.yml +++ b/.github/workflows/scheduled-event-processor.yml @@ -39,7 +39,7 @@ jobs: run: > dotnet tool install Azure.Sdk.Tools.GitHubEventProcessor - --version 1.0.0-dev.20260403.1 + --version 1.0.0-dev.20261026.5 --add-source https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json --global shell: bash diff --git a/.github/workflows/scripts/mgmt_sdk_review_contract.py b/.github/workflows/scripts/mgmt_sdk_review_contract.py index cf981238b4ea..037f0715bed7 100644 --- a/.github/workflows/scripts/mgmt_sdk_review_contract.py +++ b/.github/workflows/scripts/mgmt_sdk_review_contract.py @@ -31,6 +31,8 @@ INCOMPLETE = "Management SDK review incomplete: " SEVERITIES = ("Blocking", "Warning", "Suggestion") MAX_BYTES = 2 * 1024 * 1024 +MAX_REVIEW_REQUEST_BYTES = 9000 +MAX_INITIAL_ATTRIBUTION_ENTRIES = 8 def obj(properties): @@ -92,6 +94,7 @@ def enum(*values): { "outcome": enum("no_entries", "entries", "incomplete"), "initial_release": {"type": "boolean"}, + "omitted_count": {"type": "integer", "minimum": 0}, "reason": TEXT, "entries": array( obj( @@ -136,6 +139,7 @@ def enum(*values): "package": {"type": "string", "pattern": r"^sdk/[^/]+/azure-mgmt-[a-z0-9-]+$"}, "checks": obj({name: CHECK for name in SEMANTIC_CHECKS}), "findings": array(FINDING), + "attribution_omitted": {"type": "integer", "minimum": 0}, "attribution": array( obj( { @@ -468,10 +472,12 @@ def validate_review(data, context): entries = attribution["entries"] indexes = unique_index(entries, "entry_index", field + ".entries") trusted_entries = breaking["introducedEntries"] + omitted = attribution["omitted_count"] + require(omitted <= len(trusted_entries), field + ".omitted_count", "omitted count exceeds trusted entries") require( - set(indexes) == set(range(len(trusted_entries))), + set(indexes) == set(range(len(trusted_entries) - omitted)), field + ".entries", - "must account for every trusted introduced entry exactly once", + "must account for the retained trusted prefix exactly once; omissions must be explicit", ) incomplete = ( breaking["status"] != "complete" @@ -479,6 +485,7 @@ def validate_review(data, context): or bool(breaking["emptyBreakingChangeSections"]) or context["commitDiscovery"]["status"] != "complete" or context["packageDiscovery"]["status"] != "complete" + or bool(omitted) ) expected = "incomplete" if incomplete else ("entries" if trusted_entries else "no_entries") require( @@ -715,6 +722,27 @@ def evidence_link(url, label): + "
" + "
".join(text(issue) for issue in issues) ) + if analysis["omitted_count"]: + omitted = analysis["omitted_count"] + total = len(breaking["introducedEntries"]) + first = breaking["introducedEntries"][total - omitted] + last = breaking["introducedEntries"][-1] + omission = attribution_omission_reason(omitted, total) + unverified.append([text(name), "Breaking-change attribution truncated", text(omission)]) + attribution_sections.append( + f"**Package: {text(name)} | Unreviewed breaking changes**\n\n" + + f"**Needs human review:** {text(omission)}
" + + evidence_link( + file_url( + context, + breaking["changelogPath"], + context["latestRevision"], + first["startLine"], + last["endLine"], + ), + "Omitted changelog entries", + ) + ) findings.sort(key=lambda row: (SEVERITIES.index(row[0]), row[2], row[1])) partial = bool(unverified) or any( package["attribution"]["outcome"] == "incomplete" @@ -865,6 +893,15 @@ def schema_errors(data, schema=DRAFT_SCHEMA, path="data"): def draft_template(context): """Supply structure and trusted identities, never prepopulate review conclusions.""" + counts = [0] * len(context["breakingChangeContext"]) + remaining = MAX_INITIAL_ATTRIBUTION_ENTRIES + while remaining and any( + count < len(item["introducedEntries"]) for count, item in zip(counts, context["breakingChangeContext"]) + ): + for index, item in enumerate(context["breakingChangeContext"]): + if remaining and counts[index] < len(item["introducedEntries"]): + counts[index] += 1 + remaining -= 1 return { "schema_version": "2", "outcome": "reviewed" if context["affectedPackages"] else "not_applicable", @@ -873,6 +910,7 @@ def draft_template(context): "package": item["packagePath"], "checks": {name: {"outcome": "unverified", "reason": "", "sources": []} for name in SEMANTIC_CHECKS}, "findings": [], + "attribution_omitted": len(item["introducedEntries"]) - count, "attribution": [ { "entry_id": entry_id(entry), @@ -881,14 +919,22 @@ def draft_template(context): "sources": [], "sdk_context": [], } - for entry in item["introducedEntries"] + for entry in item["introducedEntries"][:count] ], } - for item in context["breakingChangeContext"] + for count, item in zip(counts, context["breakingChangeContext"]) ], } +def attribution_omission_reason(omitted, total): + return ( + f"{omitted} of {total} breaking-change entries were not reviewed. " + f"Attribution is bounded to {MAX_INITIAL_ATTRIBUTION_ENTRIES} entries initially and " + f"a {MAX_REVIEW_REQUEST_BYTES}-byte MCP request budget; human review is required for the omitted suffix." + ) + + def incomplete_submission(message): validate_schema(message, TEXT, "incomplete.reason") reason(message, "incomplete.reason") @@ -1054,15 +1100,20 @@ def refs(values, path, package, role="sdk", check=None): ) # SDK context is separately validated, then rendered as literal supporting prose links. entries[-1]["sdk_context"] = sdk_sources - if seen != set(trusted_entries): + omitted = package["attribution_omitted"] + retained = len(record["introducedEntries"]) - omitted + expected_ids = {entry_id(entry) for entry in record["introducedEntries"][:max(0, retained)]} + if omitted > len(trusted_entries) or seen != expected_ids: errors.append( { "code": "entry_coverage", "path": path + ".attribution", - "message": "Account for all trusted introduced entry IDs.", + "message": "Account for the retained trusted prefix; omitted suffix count must match exactly.", } ) issues = list(record["collectionIssues"]) + if omitted: + issues.append(attribution_omission_reason(omitted, len(trusted_entries))) if record["status"] != "complete" and not issues: issues.append("Breaking-change collection is incomplete.") if record["emptyBreakingChangeSections"]: @@ -1078,6 +1129,7 @@ def refs(values, path, package, role="sdk", check=None): "attribution": { "outcome": "incomplete" if issues else ("entries" if trusted_entries else "no_entries"), "initial_release": record["releaseBaseline"]["status"] == "not_applicable", + "omitted_count": omitted, "reason": "; ".join(issues), "entries": entries, }, diff --git a/.github/workflows/scripts/mgmt_sdk_review_request.jq b/.github/workflows/scripts/mgmt_sdk_review_request.jq new file mode 100644 index 000000000000..f24c34f4d363 --- /dev/null +++ b/.github/workflows/scripts/mgmt_sdk_review_request.jq @@ -0,0 +1,22 @@ +# cspell:ignore tojson + +def envelope: + {request: ({operation: $operation, draft: .} | tojson)}; + +if $operation != "check" and $operation != "preflight" then + error("Only check and preflight requests can be bounded") +else + # Include JSON string escaping, not just the unwrapped draft's byte count. + until( + (envelope | tojson | utf8bytelength) <= 9000 or + ([.packages[].attribution | length] | add // 0) == 0; + ([.packages | to_entries[] | select(.value.attribution | length > 0) | .key] | last) as $index + | .packages[$index].attribution |= .[:-1] + | .packages[$index].attribution_omitted += 1 + ) + | if (envelope | tojson | utf8bytelength) > 9000 then + error("Checks and findings exceed the 9000-byte MCP request budget even without attribution; report incomplete") + else + envelope + end +end diff --git a/.github/workflows/scripts/mgmt_sdk_review_service.py b/.github/workflows/scripts/mgmt_sdk_review_service.py index e3e43b0d50ff..61f92e3fd289 100644 --- a/.github/workflows/scripts/mgmt_sdk_review_service.py +++ b/.github/workflows/scripts/mgmt_sdk_review_service.py @@ -204,18 +204,24 @@ def call(self, request): operation = request.get("operation") if operation == "describe": contract.require(set(request) == {"operation"}, "request", "describe takes no other fields.") + draft = contract.draft_template(self.context) return { "schema": contract.DRAFT_SCHEMA, - "draft": contract.draft_template(self.context), + "draft": draft, "schemaVersion": "2", "toolingRevision": self.context["toolingRevision"], "packages": [ { "package": item["packagePath"], "requiredChecks": list(SEMANTIC_CHECKS), - "entries": [{"entry_id": entry_id(entry), **entry} for entry in item["introducedEntries"]], + "entries": [ + {"entry_id": entry_id(entry), **entry} + for entry in item["introducedEntries"][:len(package["attribution"])] + ], + "omittedEntries": package["attribution_omitted"], + "totalEntries": len(item["introducedEntries"]), } - for item in self.context["breakingChangeContext"] + for item, package in zip(self.context["breakingChangeContext"], draft["packages"]) ], "sources": [ {key: value for key, value in item.items() if key != "content"} for item in self.context["sources"] diff --git a/.github/workflows/tests/test_mgmt_sdk_review_comment.py b/.github/workflows/tests/test_mgmt_sdk_review_comment.py index 9a1c1b24c07e..4154cfb30553 100644 --- a/.github/workflows/tests/test_mgmt_sdk_review_comment.py +++ b/.github/workflows/tests/test_mgmt_sdk_review_comment.py @@ -92,7 +92,13 @@ def review(): for name in MODULE.CHECKS ], "findings": [], - "attribution": {"outcome": "no_entries", "initial_release": False, "reason": "", "entries": []}, + "attribution": { + "outcome": "no_entries", + "initial_release": False, + "omitted_count": 0, + "reason": "", + "entries": [], + }, } ], } @@ -206,6 +212,7 @@ def convert(source, package): for item in package["findings"] if item["check"] in SEMANTIC_CHECKS ], + "attribution_omitted": package["attribution"]["omitted_count"], "attribution": [ { "entry_id": entry_id(breaking["introducedEntries"][item["entry_index"]]), diff --git a/.github/workflows/tests/test_mgmt_sdk_review_reliability.py b/.github/workflows/tests/test_mgmt_sdk_review_reliability.py index 359409f60e8a..0707dbb3d7c2 100644 --- a/.github/workflows/tests/test_mgmt_sdk_review_reliability.py +++ b/.github/workflows/tests/test_mgmt_sdk_review_reliability.py @@ -861,6 +861,173 @@ def test_unique_multi_package_evidence_still_enforces_link_budget(self): contract.prepare_output(envelope(final), trusted) +class BoundedAttributionTests(unittest.TestCase): + FILTER = SCRIPT.with_name("mgmt_sdk_review_request.jq") + + def breaking_fixture(self, count=42): + draft, trusted = fixture() + entries = trusted["breakingChangeContext"][0]["introducedEntries"] + for index in range(count): + entries.append( + { + "text": f"Removed operation {index}.", + "release": "2.0.0", + "startLine": index + 5, + "endLine": index + 5, + "changeKind": "added", + } + ) + package = contract.draft_template(trusted)["packages"][0] + draft["packages"][0].update( + attribution=package["attribution"], attribution_omitted=package["attribution_omitted"] + ) + for entry in draft["packages"][0]["attribution"]: + entry["explanation"] = "The removed operation could not be located in the pinned specification." + return draft, trusted + + def bound(self, draft, operation="preflight", check=True): + result = subprocess.run( + ["jq", "--arg", "operation", operation, "-f", str(self.FILTER)], + input=json.dumps(draft, ensure_ascii=False), + capture_output=True, + encoding="utf-8", + check=check, + timeout=10, + ) + if not check: + return result + wrapper = json.loads(result.stdout) + self.assertLessEqual( + len(json.dumps(wrapper, separators=(",", ":"), ensure_ascii=False).encode()), + contract.MAX_REVIEW_REQUEST_BYTES, + ) + request = json.loads(wrapper["request"]) + self.assertEqual(operation, request["operation"]) + return request["draft"] + + def test_describe_caps_work_and_preserves_full_trusted_snapshot(self): + for count in (0, 1, 8, 9, 42, 43): + with self.subTest(count=count): + draft, trusted = self.breaking_fixture(count) + host = service.ReviewService(trusted) + description = host.call({"operation": "describe"}) + selected = min(count, contract.MAX_INITIAL_ATTRIBUTION_ENTRIES) + self.assertEqual(selected, len(description["packages"][0]["entries"])) + self.assertEqual(count - selected, description["packages"][0]["omittedEntries"]) + self.assertEqual(count, description["packages"][0]["totalEntries"]) + self.assertEqual(count, len(host.context["breakingChangeContext"][0]["introducedEntries"])) + self.assertEqual(draft["packages"][0]["attribution_omitted"], count - selected) + self.assertEqual([], contract.schema_errors(description["draft"])) + + def test_selection_is_round_robin_and_uses_each_packages_prefix(self): + _, trusted = multipackage_fixture(3) + for package in trusted["breakingChangeContext"]: + package["introducedEntries"] = [ + {"text": str(i), "release": "2.0.0", "startLine": i + 1, "endLine": i + 1, "changeKind": "added"} + for i in range(10) + ] + draft = contract.draft_template(trusted) + self.assertEqual([3, 3, 2], [len(item["attribution"]) for item in draft["packages"]]) + self.assertEqual([7, 7, 8], [item["attribution_omitted"] for item in draft["packages"]]) + for package, breaking in zip(draft["packages"], trusted["breakingChangeContext"]): + self.assertEqual( + [evidence.entry_id(item) for item in breaking["introducedEntries"][:len(package["attribution"])]], + [item["entry_id"] for item in package["attribution"]], + ) + + def test_partial_review_publishes_checked_prefix_and_explicit_omissions(self): + draft, trusted = self.breaking_fixture() + bounded = self.bound(draft) + result = service.ReviewService(trusted).call({"operation": "preflight", "draft": bounded}) + self.assertTrue(result["ok"], result) + self.assertEqual("partial", result["reviewCompleteness"]) + body = contract.prepare_output(envelope(result["submission"]["data"]), trusted)["items"][0]["body"] + self.assertIn("34 of 42 breaking-change entries were not reviewed", body) + self.assertIn("Breaking-change attribution truncated", body) + self.assertIn("Unreviewed breaking changes", body) + self.assertIn("Omitted changelog entries", body) + self.assertIn("CHANGELOG.md#L13-L46", body) + self.assertIn("Removed operation 7.", body) + self.assertNotIn("Removed operation 8.", body) + self.assertIn("README snippets", body) + self.assertNotIn("Review completeness: complete", body) + for count in (0, 1): + complete, context = self.breaking_fixture(count) + if count: + complete["packages"][0]["attribution"][0]["cause"] = "human_review" + answer = contract.preflight(self.bound(complete), context) + self.assertTrue(answer["ok"], answer) + if not count: + self.assertEqual("complete", answer["reviewCompleteness"]) + + def test_incorrect_counts_gaps_and_forged_post_preflight_omissions_are_rejected(self): + draft, trusted = self.breaking_fixture() + for mutation in ("count", "gap", "duplicate", "negative", "overflow"): + current = copy.deepcopy(draft) + package = current["packages"][0] + if mutation == "count": + package["attribution_omitted"] -= 1 + elif mutation == "gap": + package["attribution"][0]["entry_id"] = evidence.entry_id( + trusted["breakingChangeContext"][0]["introducedEntries"][8] + ) + elif mutation == "duplicate": + package["attribution"][0] = copy.deepcopy(package["attribution"][1]) + elif mutation == "negative": + package["attribution_omitted"] = -1 + else: + package["attribution_omitted"] = 43 + with self.subTest(mutation=mutation): + self.assertFalse(contract.preflight(current, trusted)["ok"]) + payload = submit(draft, trusted) + payload["items"][0]["data"]["packages"][0]["attribution_omitted"] = 0 + with self.assertRaises(contract.ReviewError): + contract.prepare_output(payload, trusted) + + def test_encoded_boundary_trims_whole_rows_without_changing_findings_or_evidence(self): + draft, _ = self.breaking_fixture(1) + package = draft["packages"][0] + package["findings"] = [ + { + "check": "README snippets", "severity": "Warning", "title": "Snippet mismatch", + "observation": "", "remediation": "Use the documented signature.", + "sources": copy.deepcopy(package["checks"]["README snippets"]["sources"]), + } + ] + request = {"operation": "preflight", "draft": draft} + wrapper = {"request": json.dumps(request, separators=(",", ":"), ensure_ascii=False)} + base_bytes = len(json.dumps(wrapper, separators=(",", ":"), ensure_ascii=False).encode()) + for size in (8999, 9000, 9001, 10240, 10241): + current = copy.deepcopy(draft) + current["packages"][0]["findings"][0]["observation"] = "x" * (size - base_bytes) + if size > 9001: + failure = self.bound(current, check=False) + self.assertNotEqual(0, failure.returncode) + self.assertIn("Checks and findings exceed", failure.stderr) + continue + bounded = self.bound(current) + self.assertEqual(current["packages"][0]["checks"], bounded["packages"][0]["checks"]) + self.assertEqual(current["packages"][0]["findings"], bounded["packages"][0]["findings"]) + if size <= contract.MAX_REVIEW_REQUEST_BYTES: + self.assertEqual(current, bounded) + elif size == 9001: + self.assertEqual([], bounded["packages"][0]["attribution"]) + self.assertEqual(1, bounded["packages"][0]["attribution_omitted"]) + + def test_unicode_and_string_escaping_are_counted_and_fixed_content_fails_explicitly(self): + draft, _ = self.breaking_fixture() + draft["packages"][0]["attribution"][0]["explanation"] = '"\\\u00e9' * 2000 + bounded = self.bound(draft) + self.assertEqual([], bounded["packages"][0]["attribution"]) + self.assertEqual(42, bounded["packages"][0]["attribution_omitted"]) + self.assertEqual(draft["packages"][0]["checks"], bounded["packages"][0]["checks"]) + draft["packages"][0]["checks"]["README snippets"]["reason"] = "x" * 10000 + failure = self.bound(draft, check=False) + self.assertNotEqual(0, failure.returncode) + self.assertIn("Checks and findings exceed", failure.stderr) + self.assertEqual("", failure.stdout) + + class PublicSpecificationReadsTests(unittest.TestCase): def read(self): return service.read_public_specification_file( @@ -1386,6 +1553,46 @@ def test_final_schema_and_host_mount_permissions(self): @unittest.skipUnless(os.environ.get("GH_AW_RUNTIME"), "Set GH_AW_RUNTIME to v0.88.8 runtime; required for release.") class PinnedPreflightRuntimeTests(unittest.TestCase): + def test_bounded_attribution_survives_ingestion_and_publication(self): + helper = BoundedAttributionTests() + draft, trusted = helper.breaking_fixture() + draft = helper.bound(draft) + result = service.ReviewService(trusted).call({"operation": "preflight", "draft": draft}) + self.assertTrue(result["ok"], result) + harness = Path(__file__).with_name("mgmt_review_runtime.cjs") + response = subprocess.run( + ["node", str(harness)], + input=json.dumps( + { + "mode": "submit", + "submissions": [result["submission"]], + "toolConfig": lock_json("GH_AW_SAFE_OUTPUTS_CONFIG")["add_comment"], + "validation": lock_json("GH_AW_VALIDATION_JSON"), + } + ), + capture_output=True, + text=True, + check=True, + timeout=30, + ) + ingested = json.loads(response.stdout)["ingestion"][0] + self.assertTrue(ingested["isValid"], ingested) + self.assertEqual(34, ingested["normalizedItem"]["data"]["packages"][0]["attribution_omitted"]) + prepared = contract.prepare_output({"items": [ingested["normalizedItem"]], "errors": []}, trusted) + response = subprocess.run( + ["node", str(harness)], + input=json.dumps({"mode": "publish", "payload": prepared}), + capture_output=True, + text=True, + check=True, + timeout=30, + ) + published = json.loads(response.stdout) + self.assertEqual(1, published["writes"]) + self.assertIn("Review completeness: partial", published["comment"]["body"]) + self.assertIn("34 of 42 breaking-change entries were not reviewed", published["comment"]["body"]) + self.assertIn("Omitted changelog entries", published["comment"]["body"]) + def test_live_manual_targets_survive_tool_ingestion_and_publication(self): harness = Path(__file__).with_name("mgmt_review_runtime.cjs") tool_config = lock_json("GH_AW_SAFE_OUTPUTS_CONFIG")["add_comment"] @@ -1604,11 +1811,15 @@ def test_actual_compiled_python_tool_and_documented_commands(self): broken = copy.deepcopy(draft) broken["packages"][0]["checks"]["README snippets"]["sources"][0]["end_line"] = 999 for operation, current in (("check", draft), ("preflight", broken), ("preflight", draft)): - expression = re.search( - rf"""(?m)^jq '([^']+operation: "{operation}"[^']+)' .* \| mcpscripts review \.$""", SOURCE - )[1] + command = re.search(rf"(?m)^jq --arg operation {operation} -f (\S+) ", SOURCE) + self.assertIsNotNone(command) + filter_path = SCRIPT.with_name(Path(command[1]).name) arguments = subprocess.run( - [jq, expression], input=json.dumps(current), capture_output=True, text=True, check=True + [jq, "--arg", "operation", operation, "-f", str(filter_path)], + input=json.dumps(current), + capture_output=True, + text=True, + check=True, ) result = subprocess.run( ["node", str(harness)], diff --git a/.github/workflows/verify-links.yml b/.github/workflows/verify-links.yml index a05822fbe01a..e65367a05ab8 100644 --- a/.github/workflows/verify-links.yml +++ b/.github/workflows/verify-links.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-slim steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 48eaad50ece2..40292925988b 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -401,6 +401,7 @@ "prevsnapshot", "prompty", "pschema", + "pscustomobject", "PSECRET", "pstn", "pydantic", @@ -592,6 +593,28 @@ "runid" ] }, + { + "filename": [ + "sdk/ai/azure-ai-projects/tests.yml", + "sdk/ai/azure-ai-projects/tests/samples/ci/**", + "sdk/ai/azure-ai-projects/tests/samples/sample_log.py" + ], + "words": [ + "axpy", + "bicepparam", + "caphostproj", + "cnotin", + "DEFINITIONID", + "foundy", + "idtyp", + "Nlcjpw", + "opusr", + "pscore", + "pytrace", + "retagged", + "unconfigured" + ] + }, { "filename": "doc/sphinx/generate_doc.py", "words": [ @@ -2205,7 +2228,7 @@ }, { "filename": ".github/**", - "words": ["llms", "DIFC", "XPIA", "svcowner", "toddysm", "chenkennt", "sffamily"] + "words": ["llms", "DIFC", "XPIA", "svcowner", "toddysm", "chenkennt", "sffamily", "pygen"] }, { "filename": "sdk/agentserver/azure-ai-agentserver-githubcopilot/**", diff --git a/common/smoketest/requirements_async.txt b/common/smoketest/requirements_async.txt index 6abb7be0f0d5..2652def36532 100644 --- a/common/smoketest/requirements_async.txt +++ b/common/smoketest/requirements_async.txt @@ -1,3 +1,3 @@ -aiohttp==3.14.3 +aiohttp<4.0 packaging==24.2 -pip==26.2.1 +pip==26.2.2 diff --git a/doc/dev/code_snippets.md b/doc/dev/code_snippets.md index de136c8232ac..f1341d623bab 100644 --- a/doc/dev/code_snippets.md +++ b/doc/dev/code_snippets.md @@ -23,7 +23,7 @@ async with AsyncPipeline(AsyncioRequestsTransport(), policies=policies) as pipel # [END asyncio] ``` -https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/samples/test_example_async.py#L63-L68 +https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/samples/test_example_async.py#L64-L69 ## Refer samples diff --git a/doc/dev/mgmt/mgmt-release-rotation-runbook.md b/doc/dev/mgmt/mgmt-release-rotation-runbook.md new file mode 100644 index 000000000000..618696512876 --- /dev/null +++ b/doc/dev/mgmt/mgmt-release-rotation-runbook.md @@ -0,0 +1,169 @@ +# Python management SDK release rotation runbook + +This runbook is for SDK maintainers covering the Go/JavaScript/Python release +rotation who are unfamiliar with Python SDK releases. It explains how to find a +release PR, use the bot's review, decide the next action, and follow the release. +Follow Steps 1-3 in order for each PR; Step 4 explains the handoff. +You do not need a local +Python environment or detailed knowledge of generated Python code for routine cases. +The reviewer on duty is the directly responsible individual (**DRI**) for routine +review and handoff. The DRI routes Python-specific decisions, including handwritten +SDK or code-generation tool changes, to the Python owner. The DRI and Python owner +can be the same person. + +Python management SDKs manage Azure resources and use package names such as +`azure-mgmt-network`. An **AutoPR** contains automatically generated SDK changes. +**CI** is the set of automated PR checks. A **release plan**, linked from the PR, +tracks the service team's release request. Released Python packages are published +to the Python Package Index (**PyPI**). + +**Contacts:** the **service team** is the submitter/contact in the PR or linked +release plan. The **Python owner** is a management SDK maintainer listed under +`/**/*mgmt*/` in [CODEOWNERS](https://github.com/Azure/azure-sdk-for-python/blob/main/.github/CODEOWNERS). +Ask the Python owner to coordinate code-generation or vendor support. For release +pipeline failures, seek engineering team support using Step 4. +Rotation assignment does not grant repository, CODEOWNER, or release permissions. + +## Step 1. Find PRs needing attention + +For a quick grouped list, ask Copilot: **"Use azsdk-python-mgmt-pr-triage to show +management SDK PRs needing attention."** The read-only +[PR triage skill](https://github.com/Azure/azure-sdk-for-python/blob/main/.github/skills/azsdk-python-mgmt-pr-triage/SKILL.md) +lists automated reviews to trigger, active reviews, missing human approvals, +changes after approval, and author follow-ups. Its instructions also include a +standalone command for clients without skill support. Check any verification +warnings; the report does not establish release readiness. + +Start from one [management AutoPR queue](https://github.com/Azure/azure-sdk-for-python/pulls?q=is%3Apr+is%3Aopen+draft%3Afalse+%22%5BAutoPR+azure-mgmt-%22+in%3Atitle+sort%3Aupdated-desc). +It lists open, non-draft PRs, most recently updated first. Open each PR and check +for these three cases: + +| Needs attention | Next action | +| --- | --- | +| No valid approval | Use the bot review and CI in Step 2, then approve when ready. | +| New commits after approval | Inspect the changes and re-approve if required, using Step 3. | +| New PR-author comments after approval | Read the request and respond or route it to the Python owner. A comment alone does not require another approval. | + +GitHub search does not isolate commits or author comments after approval. Check +the PR timeline; bot activity also updates the sort order. The queue does not +filter by `mgmt-review-needed` or `auto-release`: neither label proves readiness. +Drafts are excluded; the service team must mark them ready when intended. +Keep `HoldOn`, `blocking-release`, and explicit human holds until the owner clears +them. If approval is current and no request needs a response, do not approve again. + +## Step 2. Request and read the bot review + +Confirm the service team's release intent in the PR or release plan, including the +package, SDK version, and API version. The service team owns the +[SDK release prerequisites](https://eng.ms/docs/products/azure-developer-experience/develop/sdk-release/sdk-release-prerequisites). + +For routine review, focus on the bot comment and current CI: + +1. Read the latest **Management SDK PR review** bot comment. If it is missing or + outdated, add `mgmt-review-needed`. This label triggers + the [review workflow](https://github.com/Azure/azure-sdk-for-python/blob/main/.github/workflows/mgmt-sdk-pr-review.md). + If the label is already present, remove and reapply it only when no management + SDK review workflow run is active. Wait for the updated comment; ask the Python + owner if none appears. +2. Follow the comment's findings and next actions. Ask the service team to resolve + reported source/configuration issues. Send incomplete or unverified results, + unclear breaking changes, and Python-specific decisions to the Python owner. + Missing evidence is not a pass; the bot does not authorize breaking changes. +3. Check CI for the current revision. Wait for running checks. For failures, use + the existing failure analysis and route the fix to the service team or Python + owner. Do not restart review instead of fixing CI. +4. Once the findings and outstanding human comments are resolved, use Step 3 + to approve. Do not repeat completed bot checks or inspect every generated file. + +Leaving `mgmt-review-needed` on the PR does not request another review after a push. +Do not request another run while one is active. +To check quickly, use the PR triage skill's **Automated review active** queue, +which links the management review workflow runs associated with each PR. +Queued or waiting runs also count as active. If it reports **Review activity +unknown**, inspect the linked workflow runs or ask the Python owner; do not +assume it is safe to retrigger. Activity can change after a scan, so confirm +the linked run status immediately before removing and reapplying the label. + +## Step 3. Approve or re-approve + +Approve only if you are an authorized reviewer, the service team has confirmed +readiness, current required CI passes, review findings have justified +dispositions, and no unresolved review, evidence gap, or hold remains. +The automated comment is advisory; it never grants human approval. + +After new commits, inspect the changes since the latest applicable approval, +using that approval's commit as the comparison baseline. Request an updated bot +result when the previous result is outdated. Wait for current CI and resolve any +new findings. Ask the Python owner about API-version changes or unclear generated +changes. Re-approve only after reviewing the new changes and only if required by +the current GitHub review decision and branch requirements. An old **Approved** +event alone does not prove approval is current. + +Read new PR-author comments even when approval remains valid. Respond to the +request or route it to the Python owner; do not add a duplicate approval. + +## Step 4. Hand off merge and release follow-up + +After approval and handoff, the service team owns merge and release follow-up. +The guidance below explains what happens next; it is not an additional mandatory +rotator step. Rotators assist when the service team requests help or a release +problem is escalated to them. + +Tell the service team when review is complete and point to its release plan's +next action. The service team normally merges; an authorized DRI may help only +when the service team requests it and all branch requirements are satisfied. +Never bypass required reviews, checks, or holds. + +**Treat merge of an `auto-release` PR as a publishing action.** Confirm release +intent and timing before merge. The +[release template](https://github.com/Azure/azure-sdk-for-python/blob/main/eng/pipelines/templates/stages/archetype-python-release.yml) +uses internal post-merge CI on `main` to select changed, declared package artifacts. +This automatic path does **not** wait for the manual `package-publish` approval +gate. Do not merge early assuming another approval will stop publication. + +For post-merge follow-up, the service team (or a rotator helping with an +escalation) locates `python - ` in the +[internal Python pipelines](https://dev.azure.com/azure-sdk/internal/_build?view=folders) +and finds the run for the merged commit. Check `AutoReleasePrepare` and the target +package's `Release: ` stage. Confirm the exact version is available on +[PyPI](https://pypi.org/) and check the linked release plan's status. +Release-plan completion can fail separately from publication; do not republish +an existing version to repair tracking. + +If publishing is absent, skipped, failed, or unclear, the service team +investigates the release run and requests help when needed. +For release pipeline failures, seek engineering team support in the +[Engineering System Teams channel](https://teams.microsoft.com/l/channel/19%3A59dbfadafb5e41c4890e2cd3d74cc7ba%40thread.skype/Engineering%20System%20%F0%9F%9B%A0%EF%B8%8F?groupId=3e17dcb0-4257-4a30-b843-77f47f1d4121&tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47). +Include the PR, package/version, merged commit, pipeline link, failed/skipped stage, +and relevant error details. Ask the Python owner about Python-specific issues. +A missing `auto-release` label or artifact mapping can leave no automatic release. +Adding the label after the run is not a reliable recovery. Check for an active run +or an already published version before any retry. + +The service team must confirm intentional manual-release plans in the PR or +release plan. A missing `auto-release` label does not prove manual-release intent; +the bot cannot infer that intent from the label's absence. For a confirmed manual +release, let the Python owner coordinate the +[unified release process](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/release.md#releasing-through-unified-pipelines). +Only authorized release approvers validate and approve the intended package's +artifacts. Do not queue a second release by default, approve unrelated packages, +or upload manually with `twine`. + +## 5. Common partner questions and escalation + +| Question or symptom | Response / owner | +| --- | --- | +| "CI is green. Can you approve?" | Check readiness, the current automated result, breaking changes, and unresolved comments. Green CI alone is insufficient. | +| "You approved already. Can you approve again?" | Compare the new changes and current review requirements using Step 3. Do not add duplicate approval when none is needed. | +| "Can you mark this draft ready or remove the hold?" | Ask the service team or hold owner to confirm readiness and timing. An approved draft is still a draft. | +| "Why did TypeSpec migration introduce breaking changes?" | Point to the attribution and [breaking-change guide](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/mgmt/sdk-breaking-changes-guide.md). Ask the Python owner to resolve unintended generator/migration breaks; do not accept them merely because code is generated. | +| "The first package has no pipeline / needs a different client name." | Ask the Python owner to coordinate onboarding or pipeline preparation. The service team fixes naming in `client.tsp` and regenerates. | +| "Playback tests fail after the API version changed." | Route recording updates to the service team with the existing failure analysis and [management test guide](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/mgmt/tests.md). Do not hand-edit recordings or disable tests to obtain green CI. | +| "The PR merged. Where is the package?" | Inspect the post-merge release run and PyPI using Step 4. Escalate the exact failed stage, not a request for another PR approval. | + +Historical examples inspected on 2026-10-23 illustrate these distinctions: +[an approved draft](https://github.com/Azure/azure-sdk-for-python/pull/48930), +[an older approval with review still required](https://github.com/Azure/azure-sdk-for-python/pull/49633), +[a corrected first-release naming blocker](https://github.com/Azure/azure-sdk-for-python/pull/49457), +and [approval followed by a service-team merge/release handoff](https://github.com/Azure/azure-sdk-for-python/pull/48686). +These examples are not the live queue. diff --git a/doc/dev/packaging.md b/doc/dev/packaging.md index 3e6a8ee8031d..afbd2349edce 100644 --- a/doc/dev/packaging.md +++ b/doc/dev/packaging.md @@ -1,6 +1,6 @@ # Azure packaging -[comment]: # ( cspell:ignore myservice ) +[comment]: # ( cspell:ignore myservice azuremyservice msvc MSRUSTUP ) > **Note:** This document covers legacy packaging using `setup.py`. New packages should use `pyproject.toml` instead. See the [`sdk/template/azure-template`](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/template/azure-template) for the current package template. @@ -145,6 +145,71 @@ Since the package is Python 3 only, do NOT make this wheel universal. This usual - wheel file name suffix is `py3-none-any`, and NOT `py2.py3-none-any`. - sdist must contain a `azure/__init__.py` file that declares `azure` as a namespace package using the `pkgutil` syntax +# Native wheel builds in CI + +`sdk_build` uses `cibuildwheel` when a package declares setuptools `ext_modules` +or a `[tool.cibuildwheel]` table in `pyproject.toml`. The latter supports +non-setuptools backends, including Maturin/PyO3. The package owns its wheel +matrix, backend requirements, and native build hooks. + +For these packages, `sdk_build_package --package_type sdist` builds only the +source distribution with build isolation, which installs the backend declared +in `[build-system].requires` without invoking `cibuildwheel`. + +Set `signBinaries: true` on the package's artifact entry in the service `ci.yml` +to enable Windows and macOS wheel builds and the existing binary-signing release +path. The platform resolver uses this artifact property, not package names. + +Packages using the Microsoft Rust toolchain can opt in through +`archetype-sdk-client.yml`: + +```yaml +extends: + template: /eng/pipelines/templates/stages/archetype-sdk-client.yml + parameters: + ServiceDirectory: myservice + InstallMsRustToolchain: true + MsRustWorkingDirectory: $(Build.SourcesDirectory)/sdk/myservice/azure-myservice + MsRustToolchainFeed: https://pkgs.dev.azure.com/azure-sdk/_packaging//nuget/v3/index.json + MsRustAdditionalTargets: aarch64-pc-windows-msvc + Artifacts: + - name: azure-myservice + safeName: azuremyservice + signBinaries: true +``` + +Replace `` with an approved organization-local feed that +provides the internal Rust toolchain. No prototype test feed is selected by +default. The working directory must contain `rust-toolchain.toml`. +The installer refuses to overwrite an existing Cargo configuration in its +`CARGO_HOME`; provide a clean build environment. +Rust installation is disabled by default and applies only to wheel-build jobs +that target an artifact with `signBinaries: true`, including on Linux. +Opted-in jobs have a 240-minute timeout and a 210-minute package-build timeout; +other jobs keep their existing limits. + +Linux wheels build inside manylinux containers. The package's `before-all` hook +must install the internal toolchain and configure Cargo source replacement +inside each container. The pipeline forwards `PIP_INDEX_URL`, +`MSRUSTUP_ACCESS_TOKEN`, `MSRUSTUP_FEED_URL`, and the +`CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_*` index, token, and credential-provider +variables. Credentials stay in the build environment, not checked-in files. +On Windows, `sdk_build` provisions only the package's selected CPython targets +from the approved Azure SDK NuGet feed. Exact interpreter versions and package +names come from the installed `cibuildwheel`, including native interpreters needed +to cross-compile ARM64 wheels. +For Microsoft Rust toolchain builds, `PYTHON_ARM64_LIB_DIR` and a temporary +`PYO3_CONFIG_FILE` are set only for ARM64 targets, using that target's Python +import libraries. A temporary cibuildwheel configuration preserves the resolved +package environment and hooks without changing the checked-in package configuration +or setting a single library path for the entire job. + +This opt-in does not provision Rust for source-installing test, documentation, +or analysis jobs. Prepare those jobs separately before enabling a Rust package, +and keep real source-distribution testing. Packages with a static +`[project].version` also need version synchronization with `_version.py` for +nightly builds. No package is activated by adding this shared support. + # I already have a package that supports Python 2, can I get short version on how to udpate to Python 3 only? - Remove "universal" from setup.cfg, or completely remove the file if it was the only option diff --git a/doc/eng_sys_checks.md b/doc/eng_sys_checks.md index 85454719a670..70b186c5845a 100644 --- a/doc/eng_sys_checks.md +++ b/doc/eng_sys_checks.md @@ -587,6 +587,8 @@ python scripts/devops_tasks/verify_autorest.py --service_directory diff --git a/doc/request_builders.md b/doc/request_builders.md index 57678dd83f93..62be2eafec4e 100644 --- a/doc/request_builders.md +++ b/doc/request_builders.md @@ -17,7 +17,7 @@ Here's how to get started: >>> response >>> response.raise_for_status() ->>> response.text +>>> response.text() 'Happy to see you!' ``` @@ -137,7 +137,7 @@ For more advanced scenarios, you can also create your own [`HttpRequest`][azure_ Let's make the same request as we do in our [previous example](#use-our-request-builders) ```python -from azure.example.core.rest import HttpRequest +from azure.core.rest import HttpRequest # this URL is relative to the endpoint we passed our client request = HttpRequest("POST", "/helloWorld", @@ -252,7 +252,7 @@ with DefaultAzureCredential() as credential: ### Errors -All errors thrown by `.raise_for_error()` are [exceptions defined in `azure-core`][azure_core_exceptions]. +All errors thrown by `.raise_for_status()` are [exceptions defined in `azure-core`][azure_core_exceptions]. ### Logging diff --git a/doc/send_request.md b/doc/send_request.md index 4f6b8df3bfa2..4985a9bb644f 100644 --- a/doc/send_request.md +++ b/doc/send_request.md @@ -166,7 +166,7 @@ with DefaultAzureCredential() as credential: ### Errors -All errors thrown by `.raise_for_error()` are [exceptions defined in `azure-core`][azure_core_exceptions]. +All errors thrown by `.raise_for_status()` are [exceptions defined in `azure-core`][azure_core_exceptions]. ### Logging diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py index 9dcfbdb46ddd..569e7a310b5d 100644 --- a/doc/sphinx/conf.py +++ b/doc/sphinx/conf.py @@ -61,7 +61,7 @@ intersphinx_mapping = { # Dependencies 'python': ('https://docs.python.org/3.11', None), - 'requests': ('https://requests.kennethreitz.org/en/latest/', None), + 'requests': ('https://requests.readthedocs.io/en/latest/', None), 'aiohttp': ('https://docs.aiohttp.org/en/stable/', None), 'trio': ('https://trio.readthedocs.io/en/stable/', None), 'msal': ('https://msal-python.readthedocs.io/en/latest/', None), diff --git a/doc/sphinx/individual_build_conf.py b/doc/sphinx/individual_build_conf.py index 53ede4378bc5..39d74e2aaf9f 100644 --- a/doc/sphinx/individual_build_conf.py +++ b/doc/sphinx/individual_build_conf.py @@ -59,7 +59,7 @@ intersphinx_mapping = { # Dependencies 'python': ('https://docs.python.org/3.11', None), - 'requests': ('https://requests.kennethreitz.org/en/master/', None), + 'requests': ('https://requests.readthedocs.io/en/master/', None), 'aiohttp': ('https://aiohttp.readthedocs.io/en/stable/', None), 'trio': ('https://trio.readthedocs.io/en/stable/', None), 'msal': ('https://msal-python.readthedocs.io/en/latest/', None), diff --git a/doc/tool_usage_guide.md b/doc/tool_usage_guide.md index ba1dcdd4c24e..2565852a586c 100644 --- a/doc/tool_usage_guide.md +++ b/doc/tool_usage_guide.md @@ -33,6 +33,9 @@ The following checks are available via the `azpysdk` entrypoint. |`optional`| Invokes 'optional' requirements for a given package. | `azpysdk optional .` | |`devtest`| Tests a package against dependencies installed from a dev index. | `azpysdk devtest .` | |`update_snippet`| Updates README code snippets from sample files. | `azpysdk update_snippet .` | +|`mindependency`| Runs the test suite against the oldest published version of each declared Azure SDK dependency that satisfies the requirement range. | `azpysdk mindependency .` | +|`latestdependency`| Runs the test suite against the latest published version of each declared Azure SDK dependency that satisfies the requirement range. | `azpysdk latestdependency .` | +|`changelog`| Manages changelogs with Chronus (`add`, `verify`, `create`, `status` subcommands). | `azpysdk changelog add ` | ## Common arguments diff --git a/eng/apiview_reqs.txt b/eng/apiview_reqs.txt index 2fb4c47ac25f..2c99481d712c 100644 --- a/eng/apiview_reqs.txt +++ b/eng/apiview_reqs.txt @@ -16,4 +16,4 @@ tomlkit==0.13.2 typing_extensions==4.15.0 wrapt==1.17.2 apiview-stub-generator==0.3.31 -pip==24.0 +pip==26.2.2 diff --git a/eng/ci_tools.txt b/eng/ci_tools.txt index a946695d1882..cdbf18d0d7b8 100644 --- a/eng/ci_tools.txt +++ b/eng/ci_tools.txt @@ -7,10 +7,10 @@ pathlib2==2.3.7.post1 doc-warden==0.7.2 beautifulsoup4==4.13.3 pkginfo==1.12.1.2 -pip==24.0 -typing-extensions==4.12.2 +pip==26.2.2 +typing-extensions==4.16.0 pyproject-api==1.8.0 -cibuildwheel==2.23.3 +cibuildwheel importlib-metadata==8.5.0 build==1.2.2.post1 diff --git a/eng/common/CHANGELOG.md b/eng/common/CHANGELOG.md new file mode 100644 index 000000000000..1fb3e6ac1e8e --- /dev/null +++ b/eng/common/CHANGELOG.md @@ -0,0 +1,7 @@ +# Common engineering release history + +## Unreleased + +### Bugs Fixed + +- Release-plan completion uses the job's `AutoReleaseSdkPullRequestUrl` when no explicit `SdkPullRequest` is supplied. An absent or empty variable is initialized to an empty string at runtime before macro expansion, so manual runs do not pass an unresolved variable. Empty initialization is logged. Existing runtime values and explicit parameters are preserved. diff --git a/eng/common/pipelines/live-eval.yml b/eng/common/pipelines/live-eval.yml index e69e48005506..e18bafa65fb7 100644 --- a/eng/common/pipelines/live-eval.yml +++ b/eng/common/pipelines/live-eval.yml @@ -13,15 +13,22 @@ schedules: - main always: true # run even when main has not changed +parameters: + - name: publishDashboardResults + displayName: 'Publish trusted main results to Blob (includes pipeline-wide Azure Storage egress)' + type: boolean + default: true + variables: # Managed-pool image selection (LINUXPOOL/LINUXVMIMAGE). Repo-local. - template: /eng/pipelines/templates/variables/image.yml - # Provides the secret azuresdk-copilot-github-pat, mapped into GITHUB_TOKEN in the invoke step. - - group: AzSDK_Eval_Variable_group extends: template: /eng/common/pipelines/templates/stages/archetype-eval.yml parameters: + createDashboardBundle: true + # EvalStorageAccountName is configured by the pipeline owner, not in synced YAML. + publishDashboardResults: ${{ parameters.publishDashboardResults }} # Shared mock/live builder; select the live tier (real Cli MCP). mcpSetupTemplate: /eng/common/pipelines/templates/steps/eval-mcp-setup.yml TestType: live diff --git a/eng/common/pipelines/skill-eval.yml b/eng/common/pipelines/skill-eval.yml index 880a9d3eef22..b9e4512fc054 100644 --- a/eng/common/pipelines/skill-eval.yml +++ b/eng/common/pipelines/skill-eval.yml @@ -12,21 +12,32 @@ trigger: - eng/common/pipelines/skill-eval.yml - eng/common/pipelines/templates/jobs/** - eng/common/pipelines/templates/steps/eval-invoke.yml + - eng/common/pipelines/templates/steps/login-to-copilot.yml + - eng/common/pipelines/templates/steps/login-to-github.yml + - eng/common/scripts/login-to-github.ps1 - eng/common/pipelines/templates/steps/eval-mcp-setup.yml + - eng/common/pipelines/templates/steps/eval-publish-results.yml - eng/common/pipelines/templates/stages/archetype-eval.yml - eng/common/scripts/eval/** pr: none +parameters: + - name: publishDashboardResults + displayName: 'Publish trusted main results to Blob (includes pipeline-wide Azure Storage egress)' + type: boolean + default: true + variables: # Managed-pool image selection (LINUXPOOL/LINUXVMIMAGE). Repo-local. - template: /eng/pipelines/templates/variables/image.yml - # Provides the secret azuresdk-copilot-github-pat, mapped into GITHUB_TOKEN in the invoke step. - - group: AzSDK_Eval_Variable_group extends: template: /eng/common/pipelines/templates/stages/archetype-eval.yml parameters: + createDashboardBundle: true + # EvalStorageAccountName is configured by the pipeline owner, not in synced YAML. + publishDashboardResults: ${{ parameters.publishDashboardResults }} # Shared mock/live builder; select the mock tier. mcpSetupTemplate: /eng/common/pipelines/templates/steps/eval-mcp-setup.yml TestType: mock @@ -34,5 +45,7 @@ extends: # Single-level glob excludes azure-typespec-author/evaluate/ (its own benchmark pipeline). evalGlobs: - '*/evals/*.eval.yaml' + - 'azsdk-common-typespec-naming/evals/eval.yaml' + - 'code-review/evals/eval.yaml' # Per-shard job timeout (report-only tier). shardTimeoutInMinutes: 20 diff --git a/eng/common/pipelines/templates/jobs/apireview-hub-build-tools-base.yml b/eng/common/pipelines/templates/jobs/apireview-hub-build-tools-base.yml new file mode 100644 index 000000000000..49d6c7d8f9ee --- /dev/null +++ b/eng/common/pipelines/templates/jobs/apireview-hub-build-tools-base.yml @@ -0,0 +1,92 @@ +parameters: + - name: languageRepoResource + type: string + - name: toolsRef + type: string + - name: languageToolsRef + type: string + - name: poolName + type: string + default: 'azsdk-pool' + - name: imageOverride + type: string + default: 'ubuntu-24.04' + - name: stepList + type: stepList + default: [] + +jobs: + - job: BuildTools + displayName: Build API review tools + pool: + name: ${{ parameters.poolName }} + demands: ImageOverride -equals ${{ parameters.imageOverride }} + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - name: ToolsSourceDirectory + value: $(Pipeline.Workspace)/tools + - name: LanguageToolsSourceDirectory + value: $(Build.SourcesDirectory) + - name: BinOutputDirectory + value: $(Build.ArtifactStagingDirectory)/bin + steps: + - checkout: self + path: tools + fetchDepth: 1 + sparseCheckoutDirectories: eng + - checkout: ${{ parameters.languageRepoResource }} + path: s + fetchDepth: 1 + sparseCheckoutDirectories: eng + + - bash: | + set -Eeuo pipefail + + checkout_ref() { + local repository="$1" + local name="$2" + local ref="$3" + if [[ "$ref" != refs/heads/* && "$ref" != refs/tags/* ]]; then + echo "$name must be a fully-qualified branch or tag ref." >&2 + exit 1 + fi + git -C "$repository" fetch --depth=1 origin "$ref" + git -C "$repository" checkout --detach FETCH_HEAD + git -C "$repository" clean -ffd + echo "$name=$ref sourceCommit=$(git -C "$repository" rev-parse HEAD)" + } + + checkout_ref "$TOOLS_REPOSITORY" toolsRef "$TOOLS_REF" + checkout_ref "$LANGUAGE_REPOSITORY" languageToolsRef "$LANGUAGE_TOOLS_REF" + displayName: Resolve API review tool refs + env: + TOOLS_REF: ${{ parameters.toolsRef }} + LANGUAGE_TOOLS_REF: ${{ parameters.languageToolsRef }} + TOOLS_REPOSITORY: $(ToolsSourceDirectory) + LANGUAGE_REPOSITORY: $(LanguageToolsSourceDirectory) + + - pwsh: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path $env:BIN_OUTPUT_DIRECTORY | Out-Null + displayName: Prepare binary output directory + env: + BIN_OUTPUT_DIRECTORY: $(BinOutputDirectory) + + # Language templates publish every file needed by artifact generation to the bin directory. + - ${{ parameters.stepList }} + + - publish: $(BinOutputDirectory) + artifact: bin + displayName: Publish built tools + + - template: /eng/common/pipelines/templates/steps/apireview-hub-failure-callback.yml + parameters: + kind: target + ref: $(ApiReviewTargetRef) + condition: failed() + + - template: /eng/common/pipelines/templates/steps/apireview-hub-failure-callback.yml + parameters: + kind: base + ref: $(ApiReviewBaseRef) + condition: and(failed(), ne(variables['ApiReviewBaseRef'], '')) diff --git a/eng/common/pipelines/templates/jobs/apireview-hub-generate-artifact-base.yml b/eng/common/pipelines/templates/jobs/apireview-hub-generate-artifact-base.yml new file mode 100644 index 000000000000..ccfbfbf00793 --- /dev/null +++ b/eng/common/pipelines/templates/jobs/apireview-hub-generate-artifact-base.yml @@ -0,0 +1,213 @@ +parameters: + - name: jobName + type: string + - name: kind + type: string + values: + - base + - target + - name: requestMode + type: string + values: + - create + - update + - name: language + type: string + - name: repositoryFullName + type: string + - name: ref + type: string + - name: packageName + type: string + - name: operationId + type: string + - name: poolName + type: string + default: 'azsdk-pool' + - name: imageOverride + type: string + default: 'ubuntu-24.04' + - name: stepList + type: stepList + default: [] + +jobs: + - job: ${{ parameters.jobName }} + displayName: Generate ${{ parameters.kind }} API review artifact + pool: + name: ${{ parameters.poolName }} + demands: ImageOverride -equals ${{ parameters.imageOverride }} + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - name: BinDirectory + value: $(Pipeline.Workspace)/bin + - name: ApiReviewSourceCommit + ${{ if eq(parameters.kind, 'base') }}: + value: $(ApiReviewBaseCommit) + ${{ if eq(parameters.kind, 'target') }}: + value: $(ApiReviewTargetCommit) + - name: ArtifactOutputDirectory + value: $(Build.ArtifactStagingDirectory)/${{ parameters.kind }} + steps: + - checkout: none + + - bash: | + set -Eeuo pipefail + + if [[ "$REF" != refs/heads/* && "$REF" != refs/tags/* ]]; then + echo "ref must be a fully-qualified branch or tag ref." >&2 + exit 1 + fi + if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "A pinned 40-character source commit SHA is required." >&2 + exit 1 + fi + if [[ ! "$REPOSITORY_FULL_NAME" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "repositoryFullName must be in owner/repository form." >&2 + exit 1 + fi + if [ -z "$PACKAGE_NAME" ] || [ -z "$OPERATION_ID" ] || [ -z "$LANGUAGE" ]; then + echo "packageName, operationId, and language are required." >&2 + exit 1 + fi + + if [[ -z "$SOURCE_DIRECTORY" || "$SOURCE_DIRECTORY" == "/" ]]; then + echo "SOURCE_DIRECTORY must be a non-root path." >&2 + exit 1 + fi + rm -rf -- "$SOURCE_DIRECTORY" + mkdir -p "$SOURCE_DIRECTORY" + git -C "$SOURCE_DIRECTORY" init + git -C "$SOURCE_DIRECTORY" remote add origin "https://github.com/$REPOSITORY_FULL_NAME.git" + git -C "$SOURCE_DIRECTORY" fetch --depth=1 origin "$SOURCE_COMMIT" + git -C "$SOURCE_DIRECTORY" checkout --detach FETCH_HEAD + git -C "$SOURCE_DIRECTORY" clean -ffd + + source_commit=$(git -C "$SOURCE_DIRECTORY" rev-parse HEAD) + if [[ "${source_commit,,}" != "${SOURCE_COMMIT,,}" ]]; then + echo "Checked-out source commit does not match the requested SHA." >&2 + exit 1 + fi + echo "ref=$REF sourceCommit=$source_commit" + echo "##vso[task.setvariable variable=ApiReviewSourceCommit]$source_commit" + displayName: Clone ${{ parameters.kind }} source ref + env: + REF: ${{ parameters.ref }} + SOURCE_COMMIT: $(ApiReviewSourceCommit) + REPOSITORY_FULL_NAME: ${{ parameters.repositoryFullName }} + SOURCE_DIRECTORY: $(Build.SourcesDirectory) + PACKAGE_NAME: ${{ parameters.packageName }} + OPERATION_ID: ${{ parameters.operationId }} + LANGUAGE: ${{ parameters.language }} + + - download: current + artifact: bin + displayName: Download built tools + + - pwsh: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path $env:ARTIFACT_OUTPUT_DIRECTORY | Out-Null + displayName: Prepare artifact output directory + env: + ARTIFACT_OUTPUT_DIRECTORY: $(ArtifactOutputDirectory) + + # Run the language-specific API review generation steps. + - ${{ parameters.stepList }} + + - pwsh: | + $ErrorActionPreference = 'Stop' + + $apiPath = Join-Path $env:ARTIFACT_OUTPUT_DIRECTORY 'api.md' + $metadataPath = Join-Path $env:ARTIFACT_OUTPUT_DIRECTORY 'api.metadata.yml' + if (!(Test-Path $apiPath -PathType Leaf)) { + throw "Expected api.md was not produced at $apiPath." + } + if (!(Test-Path $metadataPath -PathType Leaf)) { + throw "Expected api.metadata.yml was not produced at $metadataPath." + } + + $metadata = @{} + foreach ($line in Get-Content $metadataPath) { + if ($line -match '^\s*([^#][^:]*):\s*(.*?)\s*$') { + $metadata[$matches[1].Trim()] = $matches[2].Trim().Trim('"', "'") + } + } + foreach ($field in 'apiHash', 'packageVersion', 'packageRelativePath') { + if ([string]::IsNullOrWhiteSpace($metadata[$field])) { + throw "api.metadata.yml must contain a non-empty $field field." + } + } + + $sourceRoot = [IO.Path]::GetFullPath($env:SOURCE_DIRECTORY) + $packagePath = [IO.Path]::GetFullPath((Join-Path $sourceRoot $metadata['packageRelativePath'])) + $sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (!$packagePath.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) -or + !(Test-Path $packagePath -PathType Container)) { + throw "packageRelativePath must resolve to an existing directory below the source root." + } + displayName: Validate ${{ parameters.kind }} API review bundle + env: + ARTIFACT_OUTPUT_DIRECTORY: $(ArtifactOutputDirectory) + SOURCE_DIRECTORY: $(Build.SourcesDirectory) + + - publish: $(ArtifactOutputDirectory) + artifact: ${{ parameters.kind }}-$(System.JobAttempt) + displayName: Publish ${{ parameters.kind }} API review artifact + + - task: AzurePowerShell@5 + displayName: Report ${{ parameters.kind }} completion + condition: always() + retryCountOnTaskFailure: 3 + inputs: + azureSubscription: $(CompletionCallbackAzureSubscription) + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + Inline: | + $ErrorActionPreference = 'Stop' + + if ([string]::IsNullOrWhiteSpace($env:CALLBACK_PROOF)) { + throw 'The callback proof is required.' + } + $tokenResponse = Get-AzAccessToken -ResourceUrl $env:CALLBACK_RESOURCE_URL -AsSecureString + $token = [System.Net.NetworkCredential]::new('', $tokenResponse.Token).Password + if ([string]::IsNullOrWhiteSpace($token)) { + throw 'The API Review Hub access token was empty.' + } + + $body = @{ + operationId = $env:OPERATION_ID + mode = $env:REQUEST_MODE + language = $env:LANGUAGE + kind = $env:KIND + ref = $env:REF + sourceCommit = $env:SOURCE_COMMIT + result = $env:JOB_RESULT + artifactName = $env:ARTIFACT_NAME + buildId = $env:BUILD_ID + project = $env:PROJECT + } | ConvertTo-Json -Compress + $headers = @{ + Authorization = "Bearer $token" + 'x-arh-callback-proof' = $env:CALLBACK_PROOF + } + $callbackUrl = "$($env:API_REVIEW_HUB_ENDPOINT.TrimEnd('/'))/api/operations/$($env:OPERATION_ID)" + Write-Host "Completion callback URL: $callbackUrl" + Write-Host "Completion callback body: $body" + Invoke-WebRequest -Method Post -Uri $callbackUrl -Headers $headers -ContentType 'application/json' -Body $body | Out-Null + env: + ${{ if eq(parameters.kind, 'base') }}: + CALLBACK_PROOF: $(BaseCallbackProof) + ${{ if eq(parameters.kind, 'target') }}: + CALLBACK_PROOF: $(TargetCallbackProof) + CALLBACK_RESOURCE_URL: $(CompletionCallbackResourceUrl) + API_REVIEW_HUB_ENDPOINT: $(ApiReviewHubEndpoint) + OPERATION_ID: ${{ parameters.operationId }} + REQUEST_MODE: ${{ parameters.requestMode }} + LANGUAGE: ${{ parameters.language }} + KIND: ${{ parameters.kind }} + REF: ${{ parameters.ref }} + SOURCE_COMMIT: $(ApiReviewSourceCommit) + JOB_RESULT: $(Agent.JobStatus) + ARTIFACT_NAME: ${{ parameters.kind }}-$(System.JobAttempt) + BUILD_ID: $(Build.BuildId) + PROJECT: $(System.TeamProject) diff --git a/eng/common/pipelines/templates/jobs/apireview-hub-job-base.yml b/eng/common/pipelines/templates/jobs/apireview-hub-job-base.yml deleted file mode 100644 index 375ca69efb37..000000000000 --- a/eng/common/pipelines/templates/jobs/apireview-hub-job-base.yml +++ /dev/null @@ -1,89 +0,0 @@ -# Base job wrapper for API Review Hub artifact creation. Language-specific job -# templates provide setup steps and the request handler provides orchestration steps. -parameters: - - name: jobName - type: string - default: CreateApiReviewArtifacts - - name: displayName - type: string - default: 'Create API review artifacts' - - name: poolName - type: string - default: 'azsdk-pool' - - name: imageOverride - type: string - default: 'ubuntu-24.04' - - name: languageRepoResource - type: string - - name: variables - type: object - default: [] - - name: sourceRepositoryFullName - type: string - default: '' - - name: sourceCheckoutDir - type: string - default: '' - - name: toolingDir - type: string - default: '$(Pipeline.Workspace)/apireview/tooling' - - name: setupSteps - type: stepList - default: [] - - name: steps - type: stepList - default: [] - -jobs: -- job: ${{ parameters.jobName }} - displayName: ${{ parameters.displayName }} - - pool: - name: ${{ parameters.poolName }} - demands: ImageOverride -equals ${{ parameters.imageOverride }} - - variables: - - template: /eng/pipelines/templates/variables/globals.yml - - name: ToolsRepo - value: $(Pipeline.Workspace)/tools - - name: LanguageRepo - value: $(Pipeline.Workspace)/lang - - name: ApiReviewRepositoryFullName - value: ${{ parameters.sourceRepositoryFullName }} - - name: ApiReviewSourceDir - value: ${{ parameters.sourceCheckoutDir }} - - name: ApiReviewToolingDir - value: ${{ parameters.toolingDir }} - - ${{ each variable in parameters.variables }}: - - ${{ variable }} - steps: - - checkout: self - path: tools - fetchDepth: 1 - sparseCheckoutDirectories: eng - - checkout: ${{ parameters.languageRepoResource }} - path: lang - fetchDepth: 1 - sparseCheckoutDirectories: eng - - - ${{ if ne(parameters.sourceCheckoutDir, '') }}: - - bash: | - set -Eeuo pipefail - - source_repo="$SOURCE_CHECKOUT_DIR" - repository_full_name="$SOURCE_REPOSITORY_FULL_NAME" - - rm -rf "$source_repo" - mkdir -p "$(dirname "$source_repo")" - - echo "Cloning $repository_full_name into $source_repo" - git clone --filter=blob:none --no-checkout "https://github.com/$repository_full_name.git" "$source_repo" - env: - SOURCE_CHECKOUT_DIR: ${{ parameters.sourceCheckoutDir }} - SOURCE_REPOSITORY_FULL_NAME: ${{ parameters.sourceRepositoryFullName }} - displayName: 'Clone API review source repository' - - - ${{ parameters.setupSteps }} - - # These steps are assembled by the generic API Review Hub request pipeline. - - ${{ parameters.steps }} \ No newline at end of file diff --git a/eng/common/pipelines/templates/jobs/eval-shard.yml b/eng/common/pipelines/templates/jobs/eval-shard.yml index fc4245111884..569f7d4fdfe5 100644 --- a/eng/common/pipelines/templates/jobs/eval-shard.yml +++ b/eng/common/pipelines/templates/jobs/eval-shard.yml @@ -1,5 +1,5 @@ # Fan-out shard job: one leg per shard runs its evals against the prebuilt MCP and publishes -# per-leg JUnit (always) + transcripts (on failure). Pass/fail is the eval VERDICT, not the +# per-leg raw JSONL + JUnit + completeness (always), and transcripts (on failure). Pass/fail is the eval VERDICT, not the # `vally` exit code (vally can exit 1 on a teardown flake after a pass). parameters: @@ -41,6 +41,8 @@ jobs: # A red shard shouldn't abort its siblings; the rollup gates in Summary. continueOnError: true timeoutInMinutes: ${{ parameters.shardTimeoutInMinutes }} + workspace: + clean: all strategy: matrix: $[ stageDependencies.Prepare.generate_eval_matrix.outputs['detect.matrix'] ] maxParallel: 10 @@ -50,9 +52,9 @@ jobs: # name instead of colliding with the prior attempt's artifact (which fails the rerun). # The Summary job keeps only the highest attempt per shard so retries supersede cleanly. - output: pipelineArtifact - displayName: 'Publish $(shardName) JUnit' + displayName: 'Publish $(shardName) raw results, JUnit and completeness' artifactName: 'eval-result-$(shardName)-$(System.JobAttempt)' - targetPath: $(Build.ArtifactStagingDirectory)/junit + targetPath: $(Build.ArtifactStagingDirectory)/eval-result condition: always() - output: pipelineArtifact displayName: 'Publish $(shardName) transcripts (on failure)' @@ -61,12 +63,13 @@ jobs: condition: failed() steps: - checkout: self + clean: true fetchDepth: 1 - task: UseNode@1 - displayName: 'Use Node.js 22.x' + displayName: 'Use Node.js 24.x' inputs: - version: 22.x + version: 24.x # 1ES Official image blocks registry.npmjs.org; restore via the azure-sdk npm mirror. - template: /eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml @@ -74,9 +77,6 @@ jobs: npmrcPath: $(Build.SourcesDirectory)/.npmrc registryUrl: https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-tools/npm/registry/ - - script: npm install --global npm@11.11.1 --userconfig $(Build.SourcesDirectory)/.npmrc - displayName: 'Use npm 11.11.1' - # Installs vally-cli + its pinned copilot-sdk executor into node_modules. - script: npm ci --userconfig $(Build.SourcesDirectory)/.npmrc displayName: 'Install Vally CLI + copilot-sdk (pinned)' @@ -111,6 +111,8 @@ jobs: env: EVAL_ARGS: $(evalArgs) + - template: /eng/common/pipelines/templates/steps/login-to-copilot.yml + # Runs the eval via the invoke template (auth-conditional in the common default). - template: ${{ parameters.evalInvokeTemplate }} parameters: @@ -122,13 +124,15 @@ jobs: - ${{ if parameters.postEvalTemplate }}: - template: ${{ parameters.postEvalTemplate }} - # JUnit is tiny — stage it always; heavy transcripts stage separately on failure. - # Also create the debug dir here so the failed()-gated debug publish always has a target - # (otherwise the publish errors "Path does not exist" when the dir was never created). + # Preserve exactly the newest invocation, including completed failed evals. + # Even an early failure publishes an incomplete marker; never fall back to stale data. - script: | - mkdir -p "$(Build.ArtifactStagingDirectory)/junit" "$(Build.ArtifactStagingDirectory)/debug" - find "$(Build.SourcesDirectory)/artifacts/vally-results/$(shardName)" -name '*.junit.xml' -exec cp {} "$(Build.ArtifactStagingDirectory)/junit/" \; - displayName: 'Collect $(shardName) JUnit' + mkdir -p "$(Build.ArtifactStagingDirectory)/debug" + node --experimental-strip-types "$(Build.SourcesDirectory)/eng/common/scripts/eval/lib/shard-results.ts" \ + --results-root "$(Build.SourcesDirectory)/artifacts/vally-results/$(shardName)" \ + --output-directory "$(Build.ArtifactStagingDirectory)/eval-result" \ + --shard-name "$(shardName)" --attempt "$(System.JobAttempt)" + displayName: 'Collect $(shardName) result data and completeness' condition: always() # On a real failure keep transcripts; strip JUnit so Summary can't double-count it. diff --git a/eng/common/pipelines/templates/jobs/eval-summarize.yml b/eng/common/pipelines/templates/jobs/eval-summarize.yml index 8d9f49d6c8c8..eddc71c6e908 100644 --- a/eng/common/pipelines/templates/jobs/eval-summarize.yml +++ b/eng/common/pipelines/templates/jobs/eval-summarize.yml @@ -5,41 +5,86 @@ parameters: - name: failOnFailedTests type: boolean default: false + - name: createDashboardBundle + type: boolean + default: false + - name: publishDashboardResults + type: boolean + default: false jobs: - job: Summarize - displayName: 'Publish + gate rollup' + displayName: 'Consolidate, publish + gate results' pool: name: $(LINUXPOOL) image: $(LINUXVMIMAGE) os: linux + workspace: + clean: all + variables: + EvalExpectedMatrix: $[ stageDependencies.Prepare.generate_eval_matrix.outputs['detect.matrix'] ] + templateContext: + outputs: + - output: pipelineArtifact + displayName: 'Retain selected build summary and saved bundle' + artifactName: 'eval-summary-$(System.JobAttempt)' + targetPath: $(Build.ArtifactStagingDirectory)/eval-summary + condition: always() steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: UseNode@1 - displayName: 'Use Node.js 22.x' + displayName: 'Use Node.js 24.x' inputs: - version: 22.x + version: 24.x - # Match *.junit.xml only so stray XML (e.g. a DLL's doc XML) can't fake a shard. + # Allowlist result data only, excluding debug, MCP, SDL and signing artifacts. - task: DownloadPipelineArtifact@2 displayName: 'Download all shard results' inputs: buildType: 'current' - itemPattern: '**/*.junit.xml' + itemPattern: | + eval-result-*/results.jsonl + eval-result-*/shard.json + eval-result-*/**/*.junit.xml targetPath: $(Pipeline.Workspace)/eval-results - # Renders the rollup to the run's Summary tab (##vso[task.uploadsummary]). + # Only attempt verification needs the current build token; rendering uses saved evidence. + - script: | + node --experimental-strip-types "$(Build.SourcesDirectory)/eng/common/scripts/eval/lib/shard-results.ts" \ + --attempts-output "$(Build.ArtifactStagingDirectory)/eval-summary/job-attempts.json" + displayName: 'Verify latest shard job attempts from current build timeline' + condition: always() + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + EVAL_EXPECTED_MATRIX: $(EvalExpectedMatrix) + - script: | node --experimental-strip-types "$(Build.SourcesDirectory)/eng/common/scripts/eval/build-eval-summary.ts" \ --results-root "$(Pipeline.Workspace)/eval-results" \ - --output-path "$(Build.ArtifactStagingDirectory)/eval-summary.md" - displayName: 'Render Markdown rollup' + --selected-root "$(Pipeline.Workspace)/eval-selected" \ + --attempts-file "$(Build.ArtifactStagingDirectory)/eval-summary/job-attempts.json" \ + --output-path "$(Build.ArtifactStagingDirectory)/eval-summary/eval-summary.md" + displayName: 'Select expected latest attempts and render complete-build summary' condition: always() + env: + EVAL_EXPECTED_MATRIX: $(EvalExpectedMatrix) + + - ${{ if or(parameters.createDashboardBundle, parameters.publishDashboardResults) }}: + - template: ../steps/eval-publish-results.yml + parameters: + publishDashboardResults: ${{ parameters.publishDashboardResults }} + # Completed failing evals are useful data: publish BEFORE the evaluation gate. - task: PublishTestResults@2 displayName: 'Publish eval rollup' + condition: succeededOrFailed() inputs: testResultsFormat: 'JUnit' - testResultsFiles: '$(Pipeline.Workspace)/eval-results/**/*.junit.xml' + testResultsFiles: '$(Pipeline.Workspace)/eval-selected/**/*.junit.xml' mergeTestResults: true failTaskOnFailedTests: ${{ parameters.failOnFailedTests }} + failTaskOnMissingResultsFile: true testRunTitle: 'Vally evals' diff --git a/eng/common/pipelines/templates/jobs/generate-eval-matrix.yml b/eng/common/pipelines/templates/jobs/generate-eval-matrix.yml index 688a1018bfb7..02c2243b8eb5 100644 --- a/eng/common/pipelines/templates/jobs/generate-eval-matrix.yml +++ b/eng/common/pipelines/templates/jobs/generate-eval-matrix.yml @@ -22,9 +22,9 @@ jobs: fetchDepth: 1 - task: UseNode@1 - displayName: 'Use Node.js 22.x' + displayName: 'Use Node.js 24.x' inputs: - version: 22.x + version: 24.x # set -f stops the shell expanding the glob `*` before patterns reach the script. - script: | diff --git a/eng/common/pipelines/templates/jobs/prepare-pipelines.yml b/eng/common/pipelines/templates/jobs/prepare-pipelines.yml index 64225f00cd94..7b0cdc8ebc0b 100644 --- a/eng/common/pipelines/templates/jobs/prepare-pipelines.yml +++ b/eng/common/pipelines/templates/jobs/prepare-pipelines.yml @@ -93,9 +93,6 @@ jobs: $internalVariableGroups = '$(AzureSDK_Maven_Release_Pipeline_Secrets) $(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations)' $testVariableGroups = '$(Secrets_for_Resource_Provisioner)' } - "android" { - $internalVariableGroups = '$(AzureSDK_Maven_Release_Pipeline_Secrets) $(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations)' - } "js" { $generatePublicCIPipeline = 'false' $internalVariableGroups = '$(NPM_Registry_Authentication) $(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations)' @@ -119,9 +116,6 @@ jobs: "cpp" { $internalVariableGroups = '$(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations) $(Secrets_for_Resource_Provisioner)' } - "ios" { - $internalVariableGroups = '$(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations) $(Secrets_for_Resource_Provisioner) $(AzureSDK_CocoaPods_Release_Pipeline_Secrets)' - } "go" { $generatePublicCIPipeline = 'false' $internalVariableGroups = '$(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations) $(Secrets_for_Resource_Provisioner)' diff --git a/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml b/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml index 69e1f99722cb..191901f27d7c 100644 --- a/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml +++ b/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml @@ -28,7 +28,7 @@ stages: # condition: dependencies.AutoReleasePrepare.outputs['ResolveAutoReleasePackages.resolve.'] # variables: stageDependencies.AutoReleasePrepare.ResolveAutoReleasePackages.outputs['resolve.'] # Emitted variables: HasAutoReleaseArtifacts (the single eligibility gate), AutoReleaseArtifactsJson, - # and ReleaseArtifact_ per declared artifact. + # ReleaseArtifact_ per declared artifact, and AutoReleaseSdkPullRequestUrl for completion. - stage: AutoReleasePrepare displayName: Auto-release prepare dependsOn: ${{ parameters.DependsOn }} diff --git a/eng/common/pipelines/templates/stages/archetype-eval.yml b/eng/common/pipelines/templates/stages/archetype-eval.yml index 4e29b4c314ce..128cc16c70ba 100644 --- a/eng/common/pipelines/templates/stages/archetype-eval.yml +++ b/eng/common/pipelines/templates/stages/archetype-eval.yml @@ -62,11 +62,22 @@ parameters: # Per-shard job timeout, passed to the shard job's timeoutInMinutes. Live tier raises it. type: number default: 20 + - name: createDashboardBundle + type: boolean + default: false + # Publication includes the required pipeline-wide AzureStorage egress policy. + - name: publishDashboardResults + type: boolean + default: false extends: # 1es-redirect picks Official (internal) vs Unofficial; its Use1ESOfficial defaults true. template: /eng/pipelines/templates/stages/1es-redirect.yml parameters: + # The checkbox controls compile-time egress; the account variable gates upload at runtime. + # Keep trust policy in YAML, without hardcoding DevOps definition IDs in shared code. + ${{ if and(parameters.publishDashboardResults, eq(variables['System.CollectionUri'], 'https://dev.azure.com/azure-sdk/'), eq(variables['System.TeamProject'], 'internal'), startsWith(variables['Build.Repository.Name'], 'Azure/'), not(endsWith(variables['Build.Repository.Name'], '-pr')), eq(variables['Build.SourceBranch'], 'refs/heads/main'), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Schedule', 'Manual')) }}: + AllowAzureStorage: true stages: - stage: Prepare displayName: 'Build MCP' @@ -98,9 +109,11 @@ extends: - stage: Summary displayName: 'Summarize results' - dependsOn: Eval + dependsOn: [Prepare, Eval] condition: always() jobs: - template: /eng/common/pipelines/templates/jobs/eval-summarize.yml parameters: failOnFailedTests: ${{ parameters.failOnFailedTests }} + createDashboardBundle: ${{ parameters.createDashboardBundle }} + publishDashboardResults: ${{ parameters.publishDashboardResults }} diff --git a/eng/common/pipelines/templates/steps/apireview-hub-failure-callback.yml b/eng/common/pipelines/templates/steps/apireview-hub-failure-callback.yml new file mode 100644 index 000000000000..91a439d0553b --- /dev/null +++ b/eng/common/pipelines/templates/steps/apireview-hub-failure-callback.yml @@ -0,0 +1,70 @@ +parameters: + - name: kind + type: string + values: + - base + - target + - name: ref + type: string + - name: condition + type: string + default: failed() + +steps: + - task: AzurePowerShell@5 + displayName: Report ${{ parameters.kind }} API review failure + condition: ${{ parameters.condition }} + retryCountOnTaskFailure: 3 + inputs: + azureSubscription: $(CompletionCallbackAzureSubscription) + azurePowerShellVersion: LatestVersion + ScriptType: InlineScript + Inline: | + $ErrorActionPreference = 'Stop' + + if ([string]::IsNullOrWhiteSpace($env:CALLBACK_PROOF)) { + throw 'The callback proof is required.' + } + $tokenResponse = Get-AzAccessToken -ResourceUrl $env:CALLBACK_RESOURCE_URL -AsSecureString + $token = [System.Net.NetworkCredential]::new('', $tokenResponse.Token).Password + if ([string]::IsNullOrWhiteSpace($token)) { + throw 'The API Review Hub access token was empty.' + } + + $body = @{ + operationId = $env:OPERATION_ID + mode = $env:REQUEST_MODE + language = $env:LANGUAGE + kind = $env:KIND + ref = $env:REF + sourceCommit = $env:SOURCE_COMMIT + result = 'Failed' + artifactName = $env:ARTIFACT_NAME + buildId = $env:BUILD_ID + project = $env:PROJECT + } | ConvertTo-Json -Compress + $headers = @{ + Authorization = "Bearer $token" + 'x-arh-callback-proof' = $env:CALLBACK_PROOF + } + $callbackUrl = "$($env:API_REVIEW_HUB_ENDPOINT.TrimEnd('/'))/api/operations/$($env:OPERATION_ID)" + Write-Host "Completion callback URL: $callbackUrl" + Write-Host "Completion callback body: $body" + Invoke-WebRequest -Method Post -Uri $callbackUrl -Headers $headers -ContentType 'application/json' -Body $body | Out-Null + env: + ${{ if eq(parameters.kind, 'base') }}: + CALLBACK_PROOF: $(BaseCallbackProof) + SOURCE_COMMIT: $(ApiReviewBaseCommit) + ${{ if eq(parameters.kind, 'target') }}: + CALLBACK_PROOF: $(TargetCallbackProof) + SOURCE_COMMIT: $(ApiReviewTargetCommit) + CALLBACK_RESOURCE_URL: $(CompletionCallbackResourceUrl) + API_REVIEW_HUB_ENDPOINT: $(ApiReviewHubEndpoint) + OPERATION_ID: $(ApiReviewOperationId) + REQUEST_MODE: $(ApiReviewRequestMode) + LANGUAGE: $(ApiReviewRequestLanguage) + KIND: ${{ parameters.kind }} + REF: ${{ parameters.ref }} + ARTIFACT_NAME: ${{ parameters.kind }}-$(System.JobAttempt) + BUILD_ID: $(Build.BuildId) + PROJECT: $(System.TeamProject) diff --git a/eng/common/pipelines/templates/steps/create-apireview-hub-artifacts-base.yml b/eng/common/pipelines/templates/steps/create-apireview-hub-artifacts-base.yml deleted file mode 100644 index e8ba1bad9f7c..000000000000 --- a/eng/common/pipelines/templates/steps/create-apireview-hub-artifacts-base.yml +++ /dev/null @@ -1,118 +0,0 @@ -# Base artifact step for API Review Hub. Language-specific templates provide the -# generation steps and this template enforces the shared artifact contract. -parameters: - - name: requestMode - type: string - - name: operationId - type: string - - name: packageName - type: string - - name: ref - type: string - - name: kind - type: string - - name: outputDir - type: string - - name: workingDir - type: string - - name: sourceDir - type: string - - name: language - type: string - - name: repositoryFullName - type: string - - name: generationSteps - type: stepList - default: [] - -steps: - - pwsh: | - $ErrorActionPreference = 'Stop' - - New-Item -ItemType Directory -Force -Path $env:OUTPUT_DIR | Out-Null - New-Item -ItemType Directory -Force -Path $env:WORKING_DIR | Out-Null - New-Item -ItemType Directory -Force -Path (Join-Path $env:WORKING_DIR 'state') | Out-Null - env: - OUTPUT_DIR: ${{ parameters.outputDir }} - WORKING_DIR: ${{ parameters.workingDir }} - displayName: 'Prepare ${{ parameters.kind }} API review bundle directories' - - - bash: | - set -Eeuo pipefail - - source_repo="$SOURCE_DIR" - ref="$REF" - - if [ ! -d "$source_repo/.git" ]; then - echo "Expected API review source repository was not cloned at $source_repo" >&2 - exit 1 - fi - - # API Review Hub must provide a fully-qualified Git ref (for example refs/heads/* or refs/tags/*). - # Keeping this explicit avoids ambiguous fetch behavior across remote refspec configurations. - if [[ -z "$ref" || "$ref" != refs/* ]]; then - echo "Expected a fully-qualified git ref (refs/*), but received: '$ref'" >&2 - exit 1 - fi - - echo "Checking out $KIND API review source ref $ref in $source_repo" - git -C "$source_repo" fetch --depth=1 origin "$ref" - git -C "$source_repo" checkout --force FETCH_HEAD - git -C "$source_repo" clean -ffd - env: - SOURCE_DIR: ${{ parameters.sourceDir }} - REF: ${{ parameters.ref }} - KIND: ${{ parameters.kind }} - displayName: 'Checkout ${{ parameters.kind }} API review source' - - - ${{ parameters.generationSteps }} - - - pwsh: | - $ErrorActionPreference = 'Stop' - - $outputDir = $env:OUTPUT_DIR - $stateDir = Join-Path $env:WORKING_DIR 'state' - $apiMdPath = Join-Path $outputDir 'api.md' - $apiMetadataPath = Join-Path $outputDir 'api.metadata.yml' - $packageRelativePathPath = Join-Path $stateDir 'package-relative-path.txt' - $versionPath = Join-Path $stateDir 'version.txt' - if (!(Test-Path -Path $apiMdPath -PathType Leaf)) { - Write-Error "Expected api.md was not produced at $apiMdPath." - } - if (!(Test-Path -Path $apiMetadataPath -PathType Leaf)) { - Write-Error "Expected api.metadata.yml was not produced at $apiMetadataPath." - } - if (!(Test-Path -Path $packageRelativePathPath -PathType Leaf)) { - Write-Error "Expected package-relative-path.txt was not produced at $packageRelativePathPath." - } - if (!(Test-Path -Path $versionPath -PathType Leaf)) { - Write-Error "Expected version.txt was not produced at $versionPath." - } - - $packageRelativePath = Get-Content -Raw -Path $packageRelativePathPath - $version = Get-Content -Raw -Path $versionPath - - $metadata = [ordered]@{ - operationId = $env:OPERATION_ID - mode = $env:REQUEST_MODE - language = $env:LANGUAGE - repositoryFullName = $env:REPOSITORY_FULL_NAME - packageName = $env:PACKAGE_NAME - packageRelativePath = $packageRelativePath.Trim() - ref = $env:REF - version = $version.Trim() - kind = $env:KIND - } - $metadata | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $outputDir 'artifact-metadata.json') -Encoding utf8 - Get-Content -Path (Join-Path $outputDir 'artifact-metadata.json') - env: - OUTPUT_DIR: ${{ parameters.outputDir }} - WORKING_DIR: ${{ parameters.workingDir }} - OPERATION_ID: ${{ parameters.operationId }} - REQUEST_MODE: ${{ parameters.requestMode }} - LANGUAGE: ${{ parameters.language }} - REPOSITORY_FULL_NAME: ${{ parameters.repositoryFullName }} - PACKAGE_NAME: ${{ parameters.packageName }} - REF: ${{ parameters.ref }} - KIND: ${{ parameters.kind }} - displayName: 'Validate and write ${{ parameters.kind }} API review bundle metadata' \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/eval-invoke.yml b/eng/common/pipelines/templates/steps/eval-invoke.yml index 2ebb5133f11d..191765af3f90 100644 --- a/eng/common/pipelines/templates/steps/eval-invoke.yml +++ b/eng/common/pipelines/templates/steps/eval-invoke.yml @@ -1,7 +1,7 @@ # Invokes `vally eval` for one shard. UseAzSdkAuthentication=false (default) runs the hermetic # node script directly. UseAzSdkAuthentication=true wraps the same run in AzureCLI@2 so a live # MCP's Azure DevOps calls inherit the service-connection identity. -# copilot-sdk authenticates the model session via GITHUB_TOKEN (from the linked eval variable group). +# The shard's login-to-copilot template supplies the executor and prompt-judge token. parameters: - name: vallyRoot @@ -32,9 +32,8 @@ steps: --shard-name "$(shardName)" \ --output-dir "$(Build.SourcesDirectory)/artifacts/vally-results/$(shardName)" \ --threshold ${{ parameters.threshold }} - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: - env: - GITHUB_TOKEN: $(azuresdk-copilot-github-pat) + env: + COPILOT_GITHUB_TOKEN: $(COPILOT_GITHUB_TOKEN) - ${{ else }}: - script: | @@ -45,6 +44,5 @@ steps: --threshold ${{ parameters.threshold }} displayName: 'vally eval $(shardName)' workingDirectory: ${{ parameters.vallyRoot }} - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: - env: - GITHUB_TOKEN: $(azuresdk-copilot-github-pat) + env: + COPILOT_GITHUB_TOKEN: $(COPILOT_GITHUB_TOKEN) diff --git a/eng/common/pipelines/templates/steps/eval-publish-results.yml b/eng/common/pipelines/templates/steps/eval-publish-results.yml new file mode 100644 index 000000000000..06ebaff1b49b --- /dev/null +++ b/eng/common/pipelines/templates/steps/eval-publish-results.yml @@ -0,0 +1,59 @@ +# Builds a data-only archive, then uses the authenticated Azure CLI to publish it. +parameters: + - name: publishDashboardResults + type: boolean + default: false + +steps: + - template: /eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml + parameters: + npmrcPath: $(Build.SourcesDirectory)/.npmrc + CustomCondition: and(succeeded(), eq(variables['EvalSummaryComplete'], 'true')) + + # Bootstrap only the pinned package manager with Node's bundled npm, as specs does. + # Isolate the archive install from any consuming repository's pnpm workspace. + - script: | + set -euo pipefail + npm install --prefix "$(Agent.TempDirectory)/eval-pnpm" --no-save --no-package-lock --ignore-scripts --no-audit --no-fund pnpm@12.6.0 + node "$(Agent.TempDirectory)/eval-pnpm/node_modules/pnpm/bin/pnpm.mjs" --ignore-workspace install --pm-on-fail=ignore --frozen-lockfile --prod --ignore-scripts + displayName: 'Install archive dependency' + workingDirectory: eng/common/scripts/eval/publisher + condition: and(succeeded(), eq(variables['EvalSummaryComplete'], 'true')) + env: + NPM_CONFIG_USERCONFIG: $(Build.SourcesDirectory)/.npmrc + + - script: | + set -euo pipefail + organization="${SYSTEM_COLLECTIONURI%/}" + node --experimental-strip-types "$(Build.SourcesDirectory)/eng/common/scripts/eval/publisher/bundle.ts" \ + --index "$(Pipeline.Workspace)/eval-results/shard-index.json" \ + --summary "$(Build.ArtifactStagingDirectory)/eval-summary/eval-summary.md" \ + --output "$(Build.ArtifactStagingDirectory)/eval-summary/dashboard-bundle.zip" \ + --organization "${organization##*/}" --project "$SYSTEM_TEAMPROJECT" \ + --repository "$BUILD_REPOSITORY_NAME" --pipeline "$BUILD_DEFINITIONNAME" \ + --definition-id "$SYSTEM_DEFINITIONID" --build-id "$BUILD_BUILDID" \ + --attempt "$SYSTEM_JOBATTEMPT" --branch "$BUILD_SOURCEBRANCH" --source-version "$BUILD_SOURCEVERSION" + displayName: 'Prepare one complete build result bundle' + condition: and(succeeded(), eq(variables['EvalSummaryComplete'], 'true')) + + # Pipeline-owned account configuration is a runtime value, not a template parameter. + # Missing configuration skips the task; PRs/foreign repos/non-main never get this task. + - ${{ if and(parameters.publishDashboardResults, eq(variables['System.CollectionUri'], 'https://dev.azure.com/azure-sdk/'), eq(variables['System.TeamProject'], 'internal'), startsWith(variables['Build.Repository.Name'], 'Azure/'), not(endsWith(variables['Build.Repository.Name'], '-pr')), eq(variables['Build.SourceBranch'], 'refs/heads/main'), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Schedule', 'Manual')) }}: + - task: AzureCLI@2 + displayName: 'Publish saved build bundle directly to Blob' + condition: and(succeeded(), eq(variables['EvalSummaryComplete'], 'true'), ne(variables['EvalStorageAccountName'], ''), eq(variables['System.CollectionUri'], 'https://dev.azure.com/azure-sdk/'), eq(variables['System.TeamProject'], 'internal'), startsWith(variables['Build.Repository.Name'], 'Azure/'), not(endsWith(variables['Build.Repository.Name'], '-pr')), eq(variables['Build.SourceBranch'], 'refs/heads/main'), in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'Schedule', 'Manual')) + timeoutInMinutes: 20 + env: + EVAL_STORAGE_ACCOUNT_NAME: $(EvalStorageAccountName) + EVAL_DASHBOARD_REFRESH_URL: $(EvalDashboardRefreshUrl) + EVAL_DASHBOARD_RESOURCE: $(EvalDashboardResource) + inputs: + azureSubscription: eval-dashboard-sc + scriptType: pscore + scriptLocation: inlineScript + # Environment mapping passes values literally, without parsing variable contents as code. + inlineScript: | + & "$(Build.SourcesDirectory)/eng/common/scripts/eval/publisher/Publish-EvalResults.ps1" ` + -StorageAccountName $env:EVAL_STORAGE_ACCOUNT_NAME ` + -BundlePath "$(Build.ArtifactStagingDirectory)/eval-summary/dashboard-bundle.zip" ` + -DashboardRefreshUrl $env:EVAL_DASHBOARD_REFRESH_URL -DashboardResource $env:EVAL_DASHBOARD_RESOURCE diff --git a/eng/common/pipelines/templates/steps/login-to-copilot.yml b/eng/common/pipelines/templates/steps/login-to-copilot.yml new file mode 100644 index 000000000000..da1c12db9143 --- /dev/null +++ b/eng/common/pipelines/templates/steps/login-to-copilot.yml @@ -0,0 +1,11 @@ +# Shared GitHub App authentication for Vally executors and prompt judges. +steps: + - template: /eng/common/pipelines/templates/steps/login-to-github.yml + parameters: + VariableNamePrefix: COPILOT_GITHUB_TOKEN + TokenRequest: + repositories: + - ${{ split(variables['Build.Repository.Name'], '/')[1] }} + permissions: + organization_copilot_requests: read + metadata: read \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/login-to-github.yml b/eng/common/pipelines/templates/steps/login-to-github.yml index 5814417bb0bb..8da4155b8c54 100644 --- a/eng/common/pipelines/templates/steps/login-to-github.yml +++ b/eng/common/pipelines/templates/steps/login-to-github.yml @@ -14,6 +14,10 @@ parameters: - name: ExportAsOutputVariable type: boolean default: false +- name: TokenRequest + # Optional GitHub access-token request body restricting repositories and permissions. + type: object + default: {} - name: ScriptDirectory default: eng/common/scripts - name: Condition @@ -33,4 +37,6 @@ steps: -InstallationTokenOwners '${{ join(''',''', parameters.TokenOwners) }}' -VariableNamePrefix '${{ parameters.VariableNamePrefix }}' -AlwaysUseOwnerSuffix:$${{ parameters.AlwaysUseOwnerSuffix }} - -ExportAsOutputVariable:$${{ parameters.ExportAsOutputVariable }} \ No newline at end of file + -ExportAsOutputVariable:$${{ parameters.ExportAsOutputVariable }} + env: + GITHUB_TOKEN_REQUEST: ${{ convertToJson(parameters.TokenRequest) }} \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/mark-release-completion.yml b/eng/common/pipelines/templates/steps/mark-release-completion.yml index 0e4824872c69..1bb3f01c37be 100644 --- a/eng/common/pipelines/templates/steps/mark-release-completion.yml +++ b/eng/common/pipelines/templates/steps/mark-release-completion.yml @@ -2,12 +2,23 @@ parameters: ConfigFileDir: '' PackageArtifactName: '' SourceRootPath: $(Build.SourcesDirectory) + ReleasePlanId: 0 + SdkPullRequest: '' steps: - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml parameters: SourceRootPath: ${{ parameters.SourceRootPath }} + # Runtime output variables must not be tested at template-expansion time. + # Initialize the optional macro before use on manual runs that have no SDK PR. + - ${{ if eq(parameters.SdkPullRequest, '') }}: + - pwsh: | + Write-Host '##vso[task.setvariable variable=AutoReleaseSdkPullRequestUrl]' + Write-Host 'AutoReleaseSdkPullRequestUrl is set to an empty string.' + displayName: Initialize optional auto-release SDK PR + condition: and(succeeded(), ne(variables['Skip.MarkReleaseCompletion'], 'true'), eq(variables['AutoReleaseSdkPullRequestUrl'], '')) + - task: AzureCLI@2 inputs: azureSubscription: opensource-api-connection @@ -16,7 +27,10 @@ steps: scriptPath: ${{ parameters.SourceRootPath }}/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 arguments: > -PackageInfoFilePath '${{ parameters.ConfigFileDir }}/${{ parameters.PackageArtifactName }}.json' -AzsdkExePath '$(AZSDK)' + -ReleasePlanId '${{ parameters.ReleasePlanId }}' + -SdkPullRequest '${{ coalesce(parameters.SdkPullRequest, '$(AutoReleaseSdkPullRequestUrl)') }}' + -PipelineUrl "$(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)" workingDirectory: $(Pipeline.Workspace) displayName: Mark package as released continueOnError: true - condition: and(succeeded(), ne(variables['Skip.MarkReleaseCompletion'], 'true')) \ No newline at end of file + condition: and(succeeded(), ne(variables['Skip.MarkReleaseCompletion'], 'true')) diff --git a/eng/common/pipelines/templates/steps/publish-1es-artifact.yml b/eng/common/pipelines/templates/steps/publish-1es-artifact.yml index 30d38828e720..55bad0c744c9 100644 --- a/eng/common/pipelines/templates/steps/publish-1es-artifact.yml +++ b/eng/common/pipelines/templates/steps/publish-1es-artifact.yml @@ -6,12 +6,14 @@ # ArtifactPath - The path we will be publishing. # CustomCondition - Used if there is additional logic necessary to prevent attempt of publish. # SbomEnabled - Set whether to auto-inject 1es pipeline template sbom tasks +# SbomGenerationTimeoutInMinutes - Budget for each injected SBOM generation task and its runner. parameters: ArtifactName: '' ArtifactPath: '' CustomCondition: true SbomEnabled: true + SbomGenerationTimeoutInMinutes: 30 steps: - pwsh: | @@ -31,6 +33,7 @@ steps: inputs: artifactName: '$(PublishArtifactName)' targetPath: '${{ parameters.ArtifactPath }}' + sbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} # Disable sbom generation by default for our public or pull request validation builds to avoid unnecessary work ${{ if or(eq(variables['System.TeamProject'], 'public'), eq(variables['Build.Reason'], 'PullRequest')) }}: sbomEnabled: false diff --git a/eng/common/pipelines/workflow-eval.yml b/eng/common/pipelines/workflow-eval.yml index d61b02b65e0a..01f5dbecb6cf 100644 --- a/eng/common/pipelines/workflow-eval.yml +++ b/eng/common/pipelines/workflow-eval.yml @@ -15,21 +15,32 @@ trigger: - eng/common/pipelines/workflow-eval.yml - eng/common/pipelines/templates/jobs/** - eng/common/pipelines/templates/steps/eval-invoke.yml + - eng/common/pipelines/templates/steps/login-to-copilot.yml + - eng/common/pipelines/templates/steps/login-to-github.yml + - eng/common/scripts/login-to-github.ps1 - eng/common/pipelines/templates/steps/eval-mcp-setup.yml + - eng/common/pipelines/templates/steps/eval-publish-results.yml - eng/common/pipelines/templates/stages/archetype-eval.yml - eng/common/scripts/eval/** pr: none +parameters: + - name: publishDashboardResults + displayName: 'Publish trusted main results to Blob (includes pipeline-wide Azure Storage egress)' + type: boolean + default: true + variables: # Defines LINUXPOOL + LINUXVMIMAGE used by the archetype's jobs. - template: /eng/pipelines/templates/variables/image.yml - # Provides the secret azuresdk-copilot-github-pat, mapped into GITHUB_TOKEN in the invoke step. - - group: AzSDK_Eval_Variable_group extends: template: /eng/common/pipelines/templates/stages/archetype-eval.yml parameters: + createDashboardBundle: true + # EvalStorageAccountName is configured by the pipeline owner, not in synced YAML. + publishDashboardResults: ${{ parameters.publishDashboardResults }} # Shared mock/live builder; select the mock tier. mcpSetupTemplate: /eng/common/pipelines/templates/steps/eval-mcp-setup.yml TestType: mock diff --git a/eng/common/scripts/Get-PackageApprovalStatus.ps1 b/eng/common/scripts/Get-PackageApprovalStatus.ps1 index 642add267c79..c3107bbc31fe 100644 --- a/eng/common/scripts/Get-PackageApprovalStatus.ps1 +++ b/eng/common/scripts/Get-PackageApprovalStatus.ps1 @@ -4,6 +4,7 @@ Checks whether a package is approved for release. .DESCRIPTION Invokes the centralized Azure SDK CLI API review release gate and fails unless its structured result approves the package. +Approval summary headings include the package name and version to distinguish packages in CI logs. .PARAMETER PackageInfoFiles Package-info JSON files containing the package name, version, and optional API hash. @@ -58,25 +59,25 @@ function Write-BackendStatus([string] $Name, [object] $Status) { } } -function Write-ApprovalSummary([object] $Response) { +function Write-ApprovalSummary([object] $Response, [string] $PackageName, [string] $PackageVersion) { $result = $Response.result $source = if ($result.PSObject.Properties["finalSource"]) { $result.finalSource } else { "unknown" } $reason = if ($result.PSObject.Properties["reason"]) { $result.reason } else { "none" } Write-Host "" - Write-Host "Approval results" + Write-Host "Approval Results ($PackageName $PackageVersion)" Write-Host "----------------" if ($result.PSObject.Properties["reviewHub"]) { - Write-BackendStatus "API Review Hub" $result.reviewHub + Write-BackendStatus "API Review Hub ($PackageName $PackageVersion)" $result.reviewHub } if ($result.PSObject.Properties["apiView"]) { Write-Host "" - Write-BackendStatus "APIView" $result.apiView + Write-BackendStatus "APIView ($PackageName $PackageVersion)" $result.apiView } $approval = if ($result.isApproved) { "APPROVED" } else { "NOT APPROVED" } Write-Host "" - Write-Host "Overall" + Write-Host "Overall ($PackageName $PackageVersion)" Write-Host " Status: $approval" Write-Host " Source: $source" Write-Host " Reason: $reason" @@ -120,7 +121,7 @@ function Test-PackageApproval([string] $PackageName, [string] $PackageVersion, [ $null -ne $response.result -and $response.result.PSObject.Properties["isApproved"] -and $response.result.isApproved -is [bool]) { - Write-ApprovalSummary $response + Write-ApprovalSummary $response $PackageName $PackageVersion } if ($exitCode -ne 0) { diff --git a/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 b/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 index 7d5a5ed122f1..da116172c747 100644 --- a/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 +++ b/eng/common/scripts/Mark-ReleasePlanCompletion.ps1 @@ -1,25 +1,51 @@ -param( +[CmdletBinding()] +param( [Parameter(Mandatory = $true)] [string]$PackageInfoFilePath, [Parameter(Mandatory = $true)] - [string]$AzsdkExePath + [string]$AzsdkExePath, + [ValidateScript({ + $id = 0 + if (-not [int]::TryParse($_, [System.Globalization.NumberStyles]::None, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$id)) + { + throw 'Release plan ID must be a nonnegative 32-bit integer, without fractions or whitespace.' + } + return $true + })] + [string]$ReleasePlanId = '0', + [string]$SdkPullRequest = '', + [string]$PipelineUrl = '' ) <# .SYNOPSIS - Marks release plan completion by identifying pull requests that changed files in a given path. + Updates release status using an explicit release-plan ID or SDK PR when supplied, otherwise the legacy package lookup. .DESCRIPTION - This script helps to mark release plan completion by finding the active release plans for a package name + Uses the requester-supplied release-plan ID for manual releases, or the triggering SDK PR + for automatic releases. azsdk resolves the existing ADO plan and validates the language/package. + TRANSITIONAL: when neither is supplied, the original package-name lookup is used and an informational message is logged + so pipelines that do not yet forward the association keep working. Remove once every pipeline forwards one. .PARAMETER PackageInfoFilePath The path to the package information file (required) or path to the directory containing package information files. .PARAMETER AzsdkExePath The path to the azsdk executable used to mark the release completion. + +.PARAMETER ReleasePlanId + The release-plan ID explicitly supplied for this manual release. Zero means no manual association. + +.PARAMETER SdkPullRequest + The unambiguous SDK PR that triggered the automatic release. Not a spec PR or a previous package PR. + +.PARAMETER PipelineUrl + The current release pipeline URL, recorded for manual and automatic releases. #> -Set-StrictMode -Version 3 +Set-StrictMode -Version 4 +$ErrorActionPreference = 'Stop' +[int]$suppliedPlanId = [int]::Parse($ReleasePlanId, [System.Globalization.CultureInfo]::InvariantCulture) . (Join-Path $PSScriptRoot common.ps1) #Validate azsdk executable path @@ -39,50 +65,79 @@ if (-Not (Test-Path $PackageInfoFilePath)) function Process-Package([string]$packageInfoPath) { # Get package info from json file created before updating version to daily dev - $pkgInfo = Get-Content $packageInfoPath | ConvertFrom-Json - $PackageName = $pkgInfo.Name - if (!$PackageName) + $pkgInfo = Get-Content -LiteralPath $packageInfoPath -Raw | ConvertFrom-Json -AsHashtable + if ($pkgInfo -isnot [System.Collections.IDictionary]) + { + Write-Warning "Package information must be a JSON object: $packageInfoPath. No release plan was updated." + return + } + $PackageName = $pkgInfo['Name'] + if ($PackageName -isnot [string] -or [string]::IsNullOrWhiteSpace($PackageName)) { Write-Host "Package name is not available in the package information file. Skipping the release plan status update for the package." return - } + } - Write-Host "Marking release completion for package, name: $PackageName" - $PackageVersion = $pkgInfo.Version - $version = [AzureEngSemanticVersion]::ParseVersionString($PackageVersion) - if (!$version) + $isLegacyLookup = ($suppliedPlanId -eq 0 -and [string]::IsNullOrWhiteSpace($SdkPullRequest)) + if ($isLegacyLookup) { - Write-Host "Failed to parse version string '$($PackageVersion)' for package '$PackageName'. Skipping the release plan status update." - return + Write-Host "LEGACY_RELEASE_PLAN_LOOKUP: Package '$PackageName' has no release-plan ID or triggering SDK PR; using the legacy package lookup. Forward ReleasePlanId or SdkPullRequest from the release pipeline." } - $sdkReleaseType = "" - if ($version.IsPrerelease) + # Do not inherit plan IDs from package metadata; unrelated bug-fix builds can reuse those files. + Write-Host "Correlating release status for package '$PackageName', language '$LanguageDisplayName'." + $releaseArgs = @("release-plan", "update-release-status", "--package-name", $PackageName, "--language", $LanguageDisplayName, "--status", "Released") + if ($suppliedPlanId -gt 0) { - $sdkReleaseType = "beta" + $releaseArgs += @("--release-plan-id", "$suppliedPlanId") } - else + if (-not [string]::IsNullOrWhiteSpace($SdkPullRequest)) { - $sdkReleaseType = "stable" + $releaseArgs += @("--sdk-pull-request", $SdkPullRequest) } - - $releaseArgs = @("release-plan", "update-release-status", "--package-name", $PackageName, "--language", $LanguageDisplayName, "--status", "Released", "--sdk-release-type", $sdkReleaseType) - if ($PackageVersion) + if (-not [string]::IsNullOrWhiteSpace($PipelineUrl)) + { + $releaseArgs += @("--release-pipeline", $PipelineUrl) + } + $PackageVersion = $pkgInfo['Version'] + $version = $null + if ($PackageVersion -is [string] -and -not [string]::IsNullOrWhiteSpace($PackageVersion)) { - $releaseArgs += @("--package-version", $PackageVersion) + $version = [AzureEngSemanticVersion]::ParseVersionString($PackageVersion) + } + if ($version) + { + $sdkReleaseType = if ($version.IsPrerelease) { 'beta' } else { 'stable' } + $releaseArgs += @("--package-version", $PackageVersion, "--sdk-release-type", $sdkReleaseType) + } + else + { + Write-Host "Package version is missing or invalid for '$PackageName'. Updating release status without version metadata." } $releaseInfo = & $AzsdkExePath @releaseArgs if ($LASTEXITCODE -ne 0) { ## Not all releases have a release plan. So we should not fail the script even if a release plan is missing. - Write-Host "Failed to mark release completion for package '$PackageName' using azsdk. Exit code: $LASTEXITCODE" + Write-Host "Release plan status was not updated for package '$PackageName'. Exit code: $LASTEXITCODE. Not all packages have a release plan." } Write-Host "Details: $releaseInfo" + return } Write-Host "Finding all package info files in the path: $PackageInfoFilePath" # Get all package info file under the directory given in input param and process -Get-ChildItem -Path $PackageInfoFilePath -Filter "*.json" | ForEach-Object { - Write-Host "Processing package info file: $_" - Process-Package $_.FullName -} \ No newline at end of file +foreach ($packageInfoFile in Get-ChildItem -Path $PackageInfoFilePath -Filter "*.json" -File) +{ + try + { + Write-Host "Processing package info file: $packageInfoFile" + Process-Package $packageInfoFile.FullName + } + catch + { + Write-Warning "Failed to update release status for '$packageInfoFile': $($_.Exception.Message)" + } +} + +# Status reporting must not change the outcome of an already completed package release. +exit 0 diff --git a/eng/common/scripts/Prepare-Release.ps1 b/eng/common/scripts/Prepare-Release.ps1 index 6d57acf6d601..75e0e3d2d909 100755 --- a/eng/common/scripts/Prepare-Release.ps1 +++ b/eng/common/scripts/Prepare-Release.ps1 @@ -10,8 +10,6 @@ This script will do the necessary book keeping work needed to release a package. This script will do a number of things when ran: - It will read the current version from the project and will have you confirm if that is the version you want to ship -- It will take the package metadata and version and update the DevOps release tracking items with that information. - - If there is existing release work item it will update it and if not it will create one. - It will validate that the changelog has a entry for the package version that you want to release as well as a timestamp. For more information see https://aka.ms/azsdk/mark-release-status. @@ -27,9 +25,6 @@ name under the 'sdk' folder (i.e for the core package which lives under 'sdk\cor Optional: Provide a specific date for when you plan to release the package. Should be the date (MM/dd/yyyy) that the given package is going to ship. If one isn't provided, then it will compute the next ship date or today's date if past the ship date for the month as the planned date. -.PARAMETER ReleaseTrackingOnly -Optional: If this switch is passed then the script will only update the release work items and not update the versions in the local repo or validate the changelog. - .PARAMETER GroupId Optional: The group ID for the package. For Java packages, if not provided, the script will prompt for input with 'com.azure' as the default. @@ -38,13 +33,6 @@ PS> ./eng/common/scripts/Prepare-Release.ps1 The most common usage is to call the script passing the package name. Once the script is finished then you will have modified project and change log files. You should make any additional changes to the change log to capture the changes and then submit the PR for the final changes before you do a release. - -.EXAMPLE -PS> ./eng/common/scripts/Prepare-Release.ps1 -ReleaseTrackingOnly - -If you aren't ready to do the final versioning changes yet but you want to update release tracking information for shiproom pass in the -ReleaseTrackingOnly. -option. This should not modify or validate anything in the repo but will update the DevOps release tracking items. Once you are ready for the verioning changes -as well then come back and run the full script again without the -ReleaseTrackingOnly option and give it the same version information you did the first time. #> [CmdletBinding()] param( @@ -52,14 +40,12 @@ param( [string]$PackageName, [string]$ServiceDirectory, [string]$ReleaseDate, # Pass Date in the form MM/dd/yyyy" - [switch]$ReleaseTrackingOnly = $false, [string]$GroupId ) Set-StrictMode -Version 3 . ${PSScriptRoot}\common.ps1 . ${PSScriptRoot}\Helpers\ApiView-Helpers.ps1 -. ${PSScriptRoot}\Helpers\DevOps-WorkItem-Helpers.ps1 # Prompt for GroupId if language is Java and GroupId is not provided if ($Language -eq 'java' -and [string]::IsNullOrEmpty($GroupId)) { @@ -158,48 +144,6 @@ if ($null -eq $newVersionParsed) exit 1 } -$result = Update-DevOpsReleaseWorkItem -language $LanguageDisplayName ` - -packageName $packageProperties.Name ` - -groupId $packageProperties.Group ` - -version $newVersion ` - -plannedDate $releaseDateString ` - -packageRepoPath $packageProperties.serviceDirectory ` - -packageType $packageProperties.SDKType ` - -packageNewLibrary $packageProperties.IsNewSDK - -if (-not $result) -{ - Write-Error "Update of the Devops Release WorkItem failed." - exit 1 -} - -# Check API status -try -{ - az account show *> $null - if (!$?) { - Write-Host 'Running az login...' - az login *> $null - } - $url = az keyvault secret show --name "APIURL" --vault-name "AzureSDKPrepRelease-KV" --query "value" --output "tsv" - $apiKey = az keyvault secret show --name "APIKEY" --vault-name "AzureSDKPrepRelease-KV" --query "value" --output "tsv" - $fullPackageNameInApiView = Get-FullPackageName -PackageInfo $packageProperties -UseColonSeparator - Check-ApiReviewStatus -PackageName $fullPackageNameInApiView -packageVersion $newVersion -Language $LanguageDisplayName -url $url -apiKey $apiKey -} -catch -{ - Write-Warning "Failed to get APIView URL and API Key from Keyvault AzureSDKPrepRelease-KV. Please check and ensure you have access to this Keyvault as reader." -} - -if ($releaseTrackingOnly) -{ - Write-Host - Write-Host "Script is running in release tracking only mode so only updating the release tracker and not updating versions locally." - Write-Host "You will need to run this script again once you are ready to update the versions to ensure the projects and changelogs contain the correct version." - - exit 0 -} - if (Test-Path "Function:SetPackageVersion") { $replaceLatestEntryTitle = $true diff --git a/eng/common/scripts/Resolve-AutoReleasePackages.ps1 b/eng/common/scripts/Resolve-AutoReleasePackages.ps1 index 60c559027476..54a3522bbb63 100644 --- a/eng/common/scripts/Resolve-AutoReleasePackages.ps1 +++ b/eng/common/scripts/Resolve-AutoReleasePackages.ps1 @@ -10,9 +10,11 @@ commit, this script: 'auto-release' label. 2. Builds a PR diff object (New-GitHubPullRequestDiffObject) from the PR's changed files and reuses the repo's existing package-detection logic (Get-PrPkgProperties) to identify the changed packages - (honoring triggering paths and deleted files while ignoring only files directly under - sdk//, not files in package subdirectories), excluding validation-only packages. - Get-PrPkgProperties delegates to each repo's own + (honoring triggering paths and deleted files while ignoring only files directly under + sdk//, not files in package subdirectories). Excludes validation-only packages and + requires an original PR changed or deleted file under each package's DirectoryPath; shared + triggering paths alone do not indicate release intent. + Get-PrPkgProperties delegates to each repo's own Get-AllPackageInfoFromRepo (language-settings.ps1), so this script works for any language repo. 3. Intersects those packages with this pipeline's declared artifacts and emits Azure DevOps output variables consumed by the release stages. Both consumption styles are emitted: @@ -58,6 +60,7 @@ The path to the azsdk executable used for release operations. Defaults to the AZ Azure DevOps output variables (reference cross-stage via dependencies..outputs['..']): - HasAutoReleaseArtifacts : 'true' if at least one declared package is releasable - AutoReleaseArtifactsJson : JSON array of the matched declared-artifact objects (or '[]') + - AutoReleaseSdkPullRequestUrl : the SDK PR selected for this release, for release-stage status correlation - ReleaseArtifact_ : 'true'/'false' per declared artifact HasAutoReleaseArtifacts is the single eligibility gate: it is 'true' only when a merged, auto-release-labeled PR changed at least one declared package, and it is emitted last so any earlier failure fails closed. @@ -101,12 +104,19 @@ catch { # Fail-closed defaults: nothing releases unless we positively determine otherwise below. Set-PipelineVariable -Name 'HasAutoReleaseArtifacts' -Value 'false' -IsOutput Set-PipelineVariable -Name 'AutoReleaseArtifactsJson' -Value '[]' -IsOutput +Set-PipelineVariable -Name 'AutoReleaseSdkPullRequestUrl' -Value '' -IsOutput foreach ($artifact in $declaredArtifacts) { if ($artifact.PSObject.Properties['safeName'] -and $artifact.safeName) { Set-PipelineVariable -Name "ReleaseArtifact_$($artifact.safeName)" -Value 'false' -IsOutput } } +function ConvertTo-AutoReleaseRelativePath([string] $Path) { + # Resolve lexically rather than using Resolve-Path so deleted and renamed files need not exist. + $fullPath = [System.IO.Path]::GetFullPath($Path.Replace('\', '/'), $RepoRoot) + return [System.IO.Path]::GetRelativePath($RepoRoot, $fullPath).Replace('\', '/') +} + function Invoke-AutoReleaseResolution { Write-Host "Resolving the auto-release pull request for commit '$CommitSha' in '$RepoId'..." $release = Get-GitHubAutoReleasePullRequestForCommit ` @@ -142,6 +152,14 @@ function Invoke-AutoReleaseResolution { -ExcludeServiceRootFiles Write-Host "PR $prLink changed $($diff.ChangedFiles.Count) file(s) and deleted $($diff.DeletedFiles.Count) file(s)." + # Keep the original PR paths separate from CI detection's exclusions and expanded triggering paths. + $releaseDiff = New-GitHubPullRequestDiffObject -PullRequestNumber $pr.number -PullRequestFiles $files + $releasePaths = @( + foreach ($path in (@($releaseDiff.ChangedFiles) + @($releaseDiff.DeletedFiles))) { + ConvertTo-AutoReleaseRelativePath $path + } + ) + $diffPath = Join-Path ([System.IO.Path]::GetTempPath()) ("autorelease-diff-" + [System.Guid]::NewGuid().ToString('N') + ".json") $diff | ConvertTo-Json -Depth 10 | Set-Content -Path $diffPath -Encoding utf8 @@ -155,12 +173,30 @@ function Invoke-AutoReleaseResolution { # Build the releasable key set. Add each changed package's name and, when the group is known, a # 'group/name' composite so that pipelines whose declared artifacts carry a groupId (e.g. Java) match # the correct group and are not confused by name collisions across groups. Packages pulled in solely - # for validation are not releasable. + # for validation or shared CI triggers alone are not releasable. $releasableKeys = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $artifactNameToPackageName = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($package in $changedPackages) { if ($package.IncludedForValidation) { continue } + if ([string]::IsNullOrWhiteSpace($package.DirectoryPath)) { + LogWarning "Skipping auto-release for '$($package.Name)': package DirectoryPath is missing." + continue + } + $packageDirectory = ConvertTo-AutoReleaseRelativePath $package.DirectoryPath + $directoryPrefix = if ($packageDirectory -eq '.') { '' } else { $packageDirectory.TrimEnd('/') + '/' } + $hasDirectChange = $false + foreach ($path in $releasePaths) { + if ($path.StartsWith($directoryPrefix, [System.StringComparison]::Ordinal)) { + $hasDirectChange = $true + break + } + } + if (-not $hasDirectChange) { + Write-Host " Skipping auto-release for '$($package.Name)': no changed or deleted files under '$packageDirectory'; shared CI triggers alone are not releasable." + continue + } + $names = @() $packageName = $null $artifactName = $null @@ -238,9 +274,8 @@ function Invoke-AutoReleaseResolution { } if($AzsdkExePath) { - $sdkPullRequestUrl = $pr.html_url Write-Host "Updating release plan for package '$packageName' (artifact name: '$name', package name: '$packageName')" - $cliArgs = @("release-plan", "update-release-status", "--package-name", $packageName, "--language", $LanguageDisplayName, "--status", "Release In Progress", "--sdk-pull-request", $sdkPullRequestUrl) + $cliArgs = @("release-plan", "update-release-status", "--package-name", $packageName, "--language", $LanguageDisplayName, "--status", "Release In Progress", "--sdk-pull-request", $prLink) if ($PipelineUrl) { $cliArgs += @("--release-pipeline", $PipelineUrl) @@ -254,7 +289,7 @@ function Invoke-AutoReleaseResolution { if ($LASTEXITCODE -ne 0) { ## Not all releases have a release plan. So we should not fail the script even if a release plan is missing. - Write-Host "Failed to update release in progress status for package '$packageName' using azsdk. Exit code: $LASTEXITCODE" + LogWarning "Failed to update release in progress status for package '$packageName' using azsdk. Exit code: $LASTEXITCODE" } } else @@ -280,6 +315,7 @@ function Invoke-AutoReleaseResolution { # Pipe (not -InputObject) with -AsArray so a single match still serializes as a JSON array, '[{...}]'. $artifactsJson = $matchedArtifacts | ConvertTo-Json -Depth 100 -Compress -AsArray Set-PipelineVariable -Name 'AutoReleaseArtifactsJson' -Value $artifactsJson -IsOutput + Set-PipelineVariable -Name 'AutoReleaseSdkPullRequestUrl' -Value $prLink -IsOutput Write-Host "Auto-release packages from PR ${prLink}: $((@($matchedArtifacts | ForEach-Object { $_.name })) -join ', ')" Set-PipelineVariable -Name 'HasAutoReleaseArtifacts' -Value 'true' -IsOutput } @@ -298,6 +334,7 @@ catch { LogWarning "Auto-release resolution failed; skipping auto-release. $($_.Exception.Message)" Set-PipelineVariable -Name 'HasAutoReleaseArtifacts' -Value 'false' -IsOutput Set-PipelineVariable -Name 'AutoReleaseArtifactsJson' -Value '[]' -IsOutput + Set-PipelineVariable -Name 'AutoReleaseSdkPullRequestUrl' -Value '' -IsOutput foreach ($artifact in $declaredArtifacts) { if ($artifact.PSObject.Properties['safeName'] -and $artifact.safeName) { Set-PipelineVariable -Name "ReleaseArtifact_$($artifact.safeName)" -Value 'false' -IsOutput diff --git a/eng/common/scripts/eval/README.md b/eng/common/scripts/eval/README.md index b934c0f9357a..cccbca480958 100644 --- a/eng/common/scripts/eval/README.md +++ b/eng/common/scripts/eval/README.md @@ -7,16 +7,194 @@ the committed `package-lock.json` instead of resolved fresh from semver ranges o It lives under `eng/common` so it syncs to every repo that consumes the shared eval pipeline templates. -- The only dependency should be `@microsoft/vally-cli`, pinned to the version CI should evaluate with. +- This evaluator package's only dependency is `@microsoft/vally-cli`, pinned to the version CI should evaluate with. - `package-lock.json` must be committed so `npm ci` is deterministic. +- Optional Blob publication is isolated in [publisher](publisher/), with + its own pnpm package/lock for **fflate only** and CLI/PowerShell tests. Shard evaluator + restores do not install it; publication adds no Node Azure SDK dependencies. + +## Complete build results and direct Blob publishing + +Shards now retain the newest invocation's raw JSONL, JUnit and a completion marker +via [shard-results](lib/shard-results.ts), including failed evaluations. +Summary checks the full Prepare matrix and selects the artifact matching each +shard's latest timeline attempt once for Markdown, the Tests tab and bundling. Missing, interrupted or +corrupt results remain incomplete; later failed attempts never fall back to older +successful artifacts. A separate step reads the current-build timeline with +`System.AccessToken` and three bounded transient retries, saving the latest attempt +evidence beside the summary. Rendering reads that file and needs no token or network. +If that exact attempt has no artifact, older JUnit is not copied or counted. +Missing or invalid timeline evidence also excludes unverified diagnostics and blocks publication. +Matching latest-attempt JUnit remains available when its result data is incomplete. +The legacy JUnit-only +summary remains supported. No historical build download or reconstruction is involved. +The bundler accepts exactly one already-selected attempt per expected shard; it +does not repeat Summary's selection or accept an ambiguous list of attempts. + +The [publishing step](../../pipelines/templates/steps/eval-publish-results.yml) restores +the archive dependency with **pnpm 12.6.0** from the **default JavaScript feed**, prepares one saved ZIP +and a checksum/identity descriptor, then runs [Publish-EvalResults.ps1](publisher/Publish-EvalResults.ps1) +under `AzureCLI@2` using `eval-dashboard-sc`. The script uses `az storage blob upload`, +not a custom SDK transport. There is no enterprise checkout, dashboard file upload, +evaluator rerun or container creation. Bundle metadata is passed explicitly by YAML; +neither bundling nor upload interprets DevOps environment variables or publishing policy. +Shard/Summary jobs and publisher CI use Node 24; the publisher requires **24.14.1+**, +matching the specs repository. Its frozen `pnpm-lock.yaml` retains the reviewed +SHA512 checksum and no private registry URLs. `--ignore-workspace` prevents a +consumer's workspace from changing the install or pulling in unrelated packages. +The pipeline uses Node's bundled npm only to bootstrap the exact pnpm version into +the agent's temporary directory, with lifecycle scripts disabled. The same +authenticated `.npmrc` is used by both commands; no global tools are changed. +`--pm-on-fail=ignore` avoids pnpm resolving/downloading itself again after that +explicit bootstrap, without changing dependency checks or the frozen lock. + +| Control | Current use | +| --- | --- | +| `publishDashboardResults` | The only checkbox; defaults to `true` in the three tools entrypoints. Set `false` to disable both upload and Azure Storage egress | +| `EvalStorageAccountName` | Pipeline-owner configuration. Upload is skipped when absent; set it to `evaltestsummary` for this dashboard | +| `EvalDashboardRefreshUrl` | Pipeline-owner configuration: `https://azsdk-eval-bue6a7dwanatgpb3.westus3-01.azurewebsites.net/api/refresh` | +| `EvalDashboardResource` | Pipeline-owner configuration: `api://258998df-81ec-460c-bdd7-56a9bdde1e48` (resource URI, not a `/.default` scope) | +| `createDashboardBundle` | Shared-template artifact-only mode; enabled for all three tools entrypoints | + +The pipeline owner must set **`EvalStorageAccountName=evaltestsummary`** and the two +dashboard variables above in each onboarded DevOps pipeline, with **queue-time +override disabled** and edit permissions restricted. These settings are trusted +deployment configuration: the owner must verify the dashboard URL/API pairing. +The task passes values through environment mapping, never by evaluating their contents +as script code. This source change does not configure DevOps or grant +access. The dashboard reads that account's existing `vally-results` container; +publishing to another account does not redirect the reader. Service-connection +authorization, approved branch checks and existing RBAC remain required. + +Publishing policy stays in YAML: organization `https://dev.azure.com/azure-sdk/`, +project `internal`, an Azure-owned repository whose name does not end in `-pr`, +exact `refs/heads/main`, and a CI, scheduled or manual reason. There is no per-repo +or definition-ID allowlist in shared code. New Azure SDK repos do not need another +shared-template edit. Personal forks, private `-pr` repos, feature branches, PRs, +pull refs and public-project runs do not get the credential-bearing task. At runtime +it also requires a complete summary and a configured storage account. + +Each consuming pipeline must opt in through its repo-local entrypoint. A repo-local +1ES redirect must expose the existing `AllowAzureStorage` hook. The pipeline owner +must authorize the publisher service connection and configure storage before uploads +can run. This support does not enable the separate TypeSpec authoring benchmarks. + +There is no separate automatic-publication, feature-branch override or network-access +flag. Publication requests the documented +[AzureStorage policy](https://aka.ms/1es/netiso/pipelinetemplates), which +keeps `DefaultDeny, CFSClean, CFSClean2, CFSClean3` and allows Azure Storage egress +for **all processes in that pipeline**, not just one container. It grants no RBAC +access. DevOps pipeline-stored variables are runtime values and cannot select a +compile-time 1ES policy: the checkbox and trusted-run checks select egress, while +the account variable additionally gates upload. An enabled trusted run can therefore +have storage egress even if the account variable is absent, but cannot upload. +Set the checkbox to `false` for artifact-only runs with no added storage egress. +Shared templates still default to no publication. Keep approvals, network restrictions +and existing consumer defaults unchanged. + +These entrypoints run real evaluations and consume model quota; there is no synthetic +pipeline mode. Completed failed evaluations publish before their existing test gate; +missing or incomplete results fail Summary and cannot publish. + +### Archive and retry contract + +For this dashboard, configure account `evaltestsummary`; the container remains `vally-results`. +An archive uses `v1//////dashboard-bundle.zip`. +It contains a schema-v1 manifest, executed plain-eval JSONL trials, Markdown and JUnit, +never debug files, MCP binaries or unrelated workspace contents. Non-executed skips +remain in raw artifacts and JUnit/Markdown; an entirely skipped shard is incomplete. + +Reader limits are **32 MiB ZIP, 128 MiB expanded, 10,000 entries and 100,000 trials**. +Canonical identities, UTC calendar dates, safe entries and counts are validated +before upload. Blob metadata records schema, SHA-256, hashed publisher identity and +the original storage time; Azure RBAC remains the authorization boundary. +Construction checks every required non-empty input, merged byte/trial limits and +the total entry count, including merged JSONL, before creating the ZIP. The final +compressed-size cap is checked afterward. Full ZIP decoding remains in contract +tests rather than reopening the archive in production. JSONL is decoded once and +parsed directly as text, with byte limits on both source and serialized records. + +Uploads use `--auth-mode login --overwrite false --if-none-match=*` with the **same +saved ZIP**; Azure CLI owns transport retries, without another script/task retry loop. +The condition is one argument so Linux does not expand a standalone `*` into file names. +`--no-progress` keeps CLI progress out of captured command results. JSON is parsed +from stdout only; captured native stderr is never mixed into JSON or echoed. +Request/socket timeouts and the task deadline bound execution. Existing archives must match metadata, +owner, checksum and size. Changed content needs a new Summary attempt, not an overwrite. +The publisher never deletes archives; the dashboard's separate identity stays read-only. +The upload is confirmed and logged before notification. No local publication status +file is written; a failed notification does not change the stored archive or fail upload. + +### Notification and recovery + +Every successful publication attempts to send only `{blobName, sha256}` to +`EvalDashboardRefreshUrl`, using a token for `EvalDashboardResource`. No production +dashboard URL or API ID is hardcoded in the script. The URL must be a canonical +HTTPS App Service address ending exactly in `/api/refresh`, with no credentials, +nondefault port, query or fragment; the resource must be `api://`. +Validation happens before requesting a notification token. Missing or invalid +configuration preserves the uploaded archive, sends no token and warns. +The storage OAuth resource `https://storage.azure.com/` remains a platform constant, +not a deployment destination. For the values above, Azure CLI requests a token for +`api://258998df-81ec-460c-bdd7-56a9bdde1e48`; the dashboard +validates the Entra v2 token's GUID `aud`, not the `api://` resource string. +The application needs `Dashboard.Refresh` permission and the receiver's Easy Auth +client allowlist. Blob permission alone does not grant notification access. + +Signals are bounded to 2 KiB, retry at most four times, honor `Retry-After` and never +follow redirects. Failure is a warning after durable upload. Browser reload/startup +and daily reconciliation recover missed signals without changing source archives. +Do not replace viewer authentication or network restrictions to make a signal succeed. + +### Publisher tests and diagnostics + +With **Node 24.14.1+ and pnpm 12.6.0**, run `pnpm --ignore-workspace install +--pm-on-fail=ignore --frozen-lockfile --prod --ignore-scripts` and +`pnpm --ignore-workspace --pm-on-fail=ignore test` from `publisher`, +then `node --experimental-strip-types --test` from this directory. +These shared Node tests do not need Tools-owned CI files, PowerShell tests or +the consuming repository's redirect settings. A consumer-copy regression runs +them without Tools files or Git history, with absent and different redirect templates. + +In Tools, run `Invoke-Pester -Path eng/common-tests/eval/Publish-EvalResults.Tests.ps1 -CI` +from the repository root for the **PowerShell 7.4+ / Pester 5 / Azure CLI** tests. That Tools-only +suite also checks the Tools CI wiring and network policy. The existing common-tests +PR pipeline runs both the shared/archive Node tests and this tagged PowerShell suite; +failures fail the CI job. Other repositories do not need to copy this Tools test setup. +Pipeline upload uses the agent's authenticated Azure CLI. +CI restores only the locked archive dependency, not the Vally evaluator. +Regression coverage includes the actual HTTP +cmdlet against loopback, invalid collision metadata, upload-before-notification +ordering and publication without local status files. +One integration test also runs the real Azure CLI against a localhost-only Blob +fixture with synthetic credentials and an isolated CLI profile. It verifies one +successful upload does not trigger a fallback read. No production storage is used. +Construction regressions exercise the exact entry/trial boundaries, aggregate +expanded bytes, final compressed bytes, required inputs and UTF-8 JSONL limits. +The loopback fixture selects the first Node application when CI has multiple Node +installations on `PATH`. Configuration tests use synthetic hosts/resources and +prove invalid targets cannot request or receive notification tokens. +Tests use synthetic fixtures and fake Azure transports; +they do not evaluate models or contact production services. A real-run test remains +separate from local checks and should use an already approved evaluation run. + +The task logs the stored Blob name and notification outcome. Failures include a +bounded operation/error code, never raw Azure responses, tokens or result contents. +CLI failures include the exit code and a fixed known storage error code, when +available. If the fallback read also fails, the original upload code is retained. +Token acquisition failures require checking the +service connection; Blob 403 requires checking its data-role scope; network failures +require checking approved egress. Preserve the original archive for conflicts, and +retry the signal or the same saved bytes for outages. Never substitute account keys, +relax TLS or broaden permissions to hide a failed request. ## TypeScript (no build step) The `*.ts` sources run directly through Node's native type stripping (erasable syntax only — -no `enum`/`namespace`/parameter properties, no emit). CI pins Node `22.x`, which strips types -unflagged on `>=22.18`; the pipeline `node` invocations and the `npm test` script pass -`--experimental-strip-types` so the same sources also run on older local Node (`>=22.6`), which -prints a harmless `ExperimentalWarning`. Relative imports use explicit `.ts` specifiers, as Node requires. +no `enum`/`namespace`/parameter properties, no emit). Matrix/Shard/Summary jobs and publisher +CI use Node `24.x`; both evaluator projects and the publisher require `>=24.14.1`, +matching azure-rest-api-specs. Relative imports use explicit `.ts` specifiers, as Node requires. +The standalone Blob publisher uses the same execution model; it adds no compiler or TypeScript runtime dependency. ## Vendored files @@ -45,6 +223,7 @@ cd ../../../.. ./eng/common/scripts/eval/node_modules/.bin/vally lint . ``` -This matches the CI job step-for-step, so a green local run on the current lockfile means a green CI run. +This uses the same locked CLI as CI. A passing local run does not validate the +agent's credentials, network access, artifact permissions or live-service behavior. A global install (`npm install -g @microsoft/vally-cli@`) still works for ad-hoc iteration, but it won't match the transitive dependency tree CI uses and isn't a substitute for the steps above when validating a version bump. diff --git a/eng/common/scripts/eval/build-eval-summary.ts b/eng/common/scripts/eval/build-eval-summary.ts index c270821e6375..43f7ef682b41 100644 --- a/eng/common/scripts/eval/build-eval-summary.ts +++ b/eng/common/scripts/eval/build-eval-summary.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { globFiles } from "./lib/glob.ts"; +import { expectedShardsFromMatrix, selectSummaryResults } from "./lib/shard-results.ts"; // Maps a JUnit file path back to its shard and job attempt. Result artifacts download into // folders named `eval-result--` (the attempt suffix keeps "Rerun failed @@ -194,7 +195,7 @@ export function getEvalSummary(resultsRoot) { * @param {Record} shards Output of getEvalSummary. * @returns {string} Markdown. */ -export function formatEvalSummaryMarkdown(shards) { +export function formatEvalSummaryMarkdown(shards, incompleteShards = []) { const all = Object.values(shards); const shardCount = all.length; @@ -213,7 +214,10 @@ export function formatEvalSummaryMarkdown(shards) { // A run that parsed zero testcases is NOT a pass — surface it as a loud NO RESULTS state. let overall; let overallIcon; - if (totalTests === 0) { + if (incompleteShards.length) { + overall = "INCOMPLETE"; + overallIcon = "⚠️"; + } else if (totalTests === 0) { overall = "NO RESULTS"; overallIcon = "⚠️"; } else if (totalFailed === 0) { @@ -231,6 +235,13 @@ export function formatEvalSummaryMarkdown(shards) { lines.push(`## ${overallIcon} Vally eval results — ${overall}`); lines.push(""); + if (incompleteShards.length) { + lines.push("> Some expected shards are missing or incomplete. The available results below are **not** a complete build result; no dashboard bundle will be uploaded."); + lines.push(""); + for (const shard of incompleteShards) lines.push(`- **${shard.shard}**: ${shard.reason}`); + lines.push(""); + } + if (totalTests === 0) { lines.push(`No scenarios were found across ${shardCount} shard(s).`); lines.push(""); @@ -313,16 +324,27 @@ export function formatEvalSummaryMarkdown(shards) { function parseArgs(argv) { const options = { outputPath: "eval-summary.md" }; for (let i = 0; i < argv.length; i++) { - const next = () => argv[++i]; - switch (argv[i]) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`); + return value; + }; + switch (arg) { case "--results-root": options.resultsRoot = next(); break; case "--output-path": options.outputPath = next(); break; + case "--selected-root": + options.selectedRoot = next(); + break; + case "--attempts-file": + options.attemptsFile = next(); + break; default: - throw new Error(`Unknown argument: ${argv[i]}`); + throw new Error(`Unknown argument: ${arg}`); } } if (!options.resultsRoot) { @@ -331,25 +353,83 @@ function parseArgs(argv) { return options; } -function main(argv) { +async function main(argv) { const options = parseArgs(argv); - const shards = getEvalSummary(options.resultsRoot); - const markdown = formatEvalSummaryMarkdown(shards); + fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true }); + fs.mkdirSync(options.resultsRoot, { recursive: true }); + let selection = null; + if (options.selectedRoot) { + let expectedShards = []; + let matrixValid = true; + try { + expectedShards = expectedShardsFromMatrix(process.env.EVAL_EXPECTED_MATRIX); + } catch { + matrixValid = false; + } + // Even a failed Prepare must use an empty destination, not stale selected results. + let jobAttempts = { schemaVersion: 1, valid: false, attempts: {} }; + if (matrixValid && options.attemptsFile) { + try { + const stat = fs.lstatSync(options.attemptsFile); + if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error("Invalid attempt evidence."); + jobAttempts = JSON.parse(fs.readFileSync(options.attemptsFile, "utf8")); + } + catch { + console.warn("##vso[task.logissue type=warning]Latest shard attempts could not be verified; Summary will remain incomplete and refuse publication."); + } + } + selection = selectSummaryResults({ resultsRoot: options.resultsRoot, + selectedRoot: options.selectedRoot, expectedShards, jobAttempts }); + if (!matrixValid) { + selection.status.push({ shard: "Prepare", attempt: null, complete: false, + reason: "The expected shard matrix is unavailable or invalid; check the Prepare stage." }); + } + } + const shards = getEvalSummary(options.selectedRoot ?? options.resultsRoot); + if (selection) { + for (const entry of selection.status) { + if (entry.complete && !shards[entry.shard]?.total) { + entry.complete = false; + entry.reason = "The selected JUnit contains no evaluation testcases."; + } + } + selection.complete = selection.status.every((entry) => entry.complete); + selection.shardInput.complete = selection.complete; + fs.writeFileSync(path.join(options.resultsRoot, "shard-index.json"), JSON.stringify(selection.shardInput, null, 2) + "\n"); + } + let markdown = formatEvalSummaryMarkdown(shards, selection?.status.filter((entry) => !entry.complete) ?? []); + if (selection) { + markdown += "\n### Selected shard attempts\n\n| Shard | Attempt | Result data |\n| --- | ---: | --- |\n"; + for (const entry of selection.status) markdown += `| ${entry.shard} | ${entry.attempt ?? "—"} | ${entry.complete ? "Complete" : "Incomplete"} |\n`; + fs.writeFileSync(path.join(path.dirname(options.outputPath), "eval-summary.json"), JSON.stringify({ + schemaVersion: 1, complete: selection.complete, shards: selection.status, + totals: Object.values(shards).reduce((totals, shard) => ({ scenarios: totals.scenarios + shard.total, + failed: totals.failed + shard.failed, skipped: totals.skipped + shard.skipped }), { scenarios: 0, failed: 0, skipped: 0 }), + }, null, 2) + "\n"); + } fs.writeFileSync(options.outputPath, markdown, "utf8"); console.log(markdown); if (process.env.TF_BUILD) { console.log(`##vso[task.uploadsummary]${path.resolve(options.outputPath)}`); + if (selection) console.log(`##vso[task.setvariable variable=EvalSummaryComplete]${selection.complete}`); } + // Completed evaluation failures remain governed by failOnFailedTests. Missing result + // data is an infrastructure failure, reported after retaining a diagnostic summary. + if (selection && !selection.complete) process.exitCode = 1; + return shards; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { - main(process.argv.slice(2)); + await main(process.argv.slice(2)); } catch (error) { + if (process.env.TF_BUILD && process.argv.includes("--selected-root")) { + console.log("##vso[task.setvariable variable=EvalSummaryComplete]false"); + } console.error(error.message); process.exit(1); } diff --git a/eng/common/scripts/eval/lib/shard-results.ts b/eng/common/scripts/eval/lib/shard-results.ts new file mode 100644 index 000000000000..8160e51f221b --- /dev/null +++ b/eng/common/scripts/eval/lib/shard-results.ts @@ -0,0 +1,274 @@ +// Dependency-free artifact staging/selection shared by eval pipeline consumers. +// Publish an incomplete marker even when an invocation fails before producing results; +// a later incomplete attempt must never fall back to an older successful attempt. +import fs from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { parseArgs } from "node:util"; +import { pathToFileURL } from "node:url"; +import { globFiles } from "./glob.ts"; + +// No individual result can exceed the bundle's expanded-size ceiling. Check before +// copying/reading whole files; completion metadata should never approach this size. +const MAX_RESULT_BYTES = 128 * 1024 * 1024; +const MAX_METADATA_BYTES = 64 * 1024; +const MISSING_RESULTS = "The invocation is missing trials, JUnit, or its final run-summary."; + +function validShard(name) { + return typeof name === "string" && /^[A-Za-z0-9_][A-Za-z0-9_-]{0,199}$/.test(name); +} + +export function expectedShardsFromMatrix(value) { + const matrix = typeof value === "string" ? JSON.parse(value) : value; + if (!matrix || typeof matrix !== "object" || Array.isArray(matrix)) { + throw new Error("The Prepare stage's eval matrix is required to check result completeness."); + } + const names = Object.values(matrix).map((entry) => entry?.shardName); + if (!names.length || names.some((name) => !validShard(name)) || new Set(names).size !== names.length) { + throw new Error("The eval matrix must contain unique, non-empty shard names."); + } + return names.sort(); +} + +// The artifact list cannot reveal a newer retry that lost its agent before publishing. +export function latestShardAttempts(timeline, matrix) { + expectedShardsFromMatrix(matrix); + if (!Array.isArray(timeline?.records)) throw new Error("The current build timeline is required."); + const attempts = {}; + for (const [key, entry] of Object.entries(matrix)) { + if (!validShard(key)) throw new Error("Invalid matrix leg identity."); + const jobs = timeline.records.filter(record => record?.type === "Job" && record.identifier === `Eval.RunShard.${key}`); + for (const record of jobs) { + if (!Number.isSafeInteger(record.attempt) || record.attempt < 1) throw new Error("Invalid timeline attempt."); + const current = { attempt: record.attempt, complete: record.state === "completed" && + ["succeeded", "succeededWithIssues", "failed"].includes(record.result) }; + const previous = attempts[entry.shardName]; + if (previous?.attempt === current.attempt && previous.complete !== current.complete) throw new Error("Ambiguous timeline attempt."); + if (!previous || previous.attempt < current.attempt) attempts[entry.shardName] = current; + } + if (!attempts[entry.shardName]) throw new Error("An expected matrix job is missing from the current build timeline."); + } + return { schemaVersion: 1, valid: true, attempts }; +} + +export async function readShardAttempts(env, { fetchImpl = fetch, wait = delay } = {}) { + const base = new URL(env.SYSTEM_COLLECTIONURI), matrix = JSON.parse(env.EVAL_EXPECTED_MATRIX); + expectedShardsFromMatrix(matrix); + if (base.protocol !== "https:" || base.hostname !== "dev.azure.com" || base.port || base.username || base.password || base.search || base.hash || + !/^\/[a-z0-9][a-z0-9-]{0,99}\/$/i.test(base.pathname) || !env.SYSTEM_ACCESSTOKEN || + !/^[a-f0-9-]{36}$/i.test(env.SYSTEM_TEAMPROJECTID ?? "") || !/^[1-9][0-9]*$/.test(env.BUILD_BUILDID ?? "")) { + throw new Error("Current build identity/token missing."); + } + const url = new URL(`${env.SYSTEM_TEAMPROJECTID}/_apis/build/builds/${env.BUILD_BUILDID}/timeline?api-version=7.1`, base); + for (let attempt = 0; attempt < 3; attempt++) { + let response; + try { + response = await fetchImpl(url, { headers: { authorization: `Bearer ${env.SYSTEM_ACCESSTOKEN}` }, + redirect: "error", signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error("Timeline could not be read."); + return latestShardAttempts(await response.json(), matrix); + } catch (error) { + if ((response && ![408, 429, 500, 502, 503, 504].includes(response.status)) || attempt === 2) throw error; + } finally { await response?.body?.cancel().catch(() => {}); } + const header = response?.headers?.get("retry-after"), seconds = Number(header); + const milliseconds = header && Number.isFinite(seconds) ? seconds * 1000 : header ? Date.parse(header) - Date.now() : NaN; + await wait(Math.min(30_000, Math.max(0, Number.isFinite(milliseconds) ? milliseconds : 1000 * 2 ** attempt))); + } +} + +function emptyDirectory(directory) { + fs.mkdirSync(directory, { recursive: true }); + if (!fs.lstatSync(directory).isDirectory() || fs.readdirSync(directory).length) { + throw new Error(`Output directory must be empty and not a symbolic link: ${directory}`); + } +} + +function checkResultFile(file, limit = MAX_RESULT_BYTES) { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > limit) { + throw new Error("Result files must be regular files within the size limit."); + } +} + +function readResultFile(file, limit = MAX_RESULT_BYTES) { + checkResultFile(file, limit); + return fs.readFileSync(file, "utf8"); +} + +function copyResultFile(source, destination) { + checkResultFile(source); + fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); +} + +function inspectInvocation(file) { + const result = { complete: false, trials: 0, reason: MISSING_RESULTS }; + try { + let summary; + // Iterate lines without allocating an array proportional to the newline count. + for (const [line] of readResultFile(file).matchAll(/[^\n]+/g)) { + if (!line.trim()) continue; + const record = JSON.parse(line); + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error("Invalid JSONL record."); + } + if (record.type === "run-summary") summary = record; + else if ((record.type === undefined || record.type === "trial-result") && ["success", "error"].includes(record.status)) { + result.trials++; + summary = undefined; + } + } + result.complete = Boolean(result.trials && Array.isArray(summary?.evals) && summary.evals.length); + result.reason = result.complete ? null : MISSING_RESULTS; + } catch { + // Do not log JSON.parse errors: they can include raw evaluation content. + result.reason = "The invocation contains incomplete, invalid, unreadable, or oversized JSONL."; + } + return result; +} + +export function stageShardResults({ resultsRoot, outputDirectory, shardName, attempt }) { + if (!validShard(shardName) || !Number.isSafeInteger(attempt) || attempt < 1) { + throw new Error("A valid shard name and positive job attempt are required."); + } + emptyDirectory(outputDirectory); + fs.mkdirSync(path.join(outputDirectory, "junit")); + const metadata = { + schemaVersion: 1, shard: shardName, attempt, complete: false, trials: 0, + reason: "No results.jsonl was produced.", + }; + const marker = path.join(outputDirectory, "shard.json"); + fs.writeFileSync(marker, JSON.stringify(metadata, null, 2) + "\n"); + try { + // Match lib/verdict.ts exactly: globFiles sorts paths, and the last path wins + // an mtime tie. localeCompare sorting would choose a different invocation. + let source; + let newest = -Infinity; + for (const file of globFiles(resultsRoot, "**/results.jsonl")) { + const mtime = fs.statSync(file).mtimeMs; + if (mtime >= newest) { + newest = mtime; + source = file; + } + } + if (source) { + const junit = globFiles(path.dirname(source), "**/*.junit.xml"); + let junitComplete = junit.length > 0; + for (const [index, file] of junit.entries()) { + try { + copyResultFile(file, path.join(outputDirectory, "junit", `${index}.junit.xml`)); + } catch { + junitComplete = false; + } + } + const stagedResults = path.join(outputDirectory, "results.jsonl"); + copyResultFile(source, stagedResults); + // Inspect the staged snapshot, not a source file that could still change. + Object.assign(metadata, inspectInvocation(stagedResults)); + if (!junitComplete) { + metadata.complete = false; + metadata.reason ??= MISSING_RESULTS; + } + } + } catch { + metadata.complete = false; + metadata.reason = "The invocation contains unreadable or oversized result files."; + } + fs.writeFileSync(marker, JSON.stringify(metadata, null, 2) + "\n"); + return metadata; +} + +export function selectSummaryResults({ resultsRoot, selectedRoot, expectedShards, jobAttempts }) { + if (!Array.isArray(expectedShards) || expectedShards.some((name) => !validShard(name)) || + new Set(expectedShards).size !== expectedShards.length) { + throw new Error("Expected shards must be unique, valid shard names from Prepare."); + } + emptyDirectory(selectedRoot); + const expected = new Set(expectedShards); + const timeline = jobAttempts?.schemaVersion === 1 && jobAttempts.valid === true ? jobAttempts.attempts : null; + const selected = new Map(); + const seen = new Set(); + for (const entry of fs.readdirSync(resultsRoot, { withFileTypes: true })) { + const match = /^eval-result-(.+)-(\d+)$/.exec(entry.name); + if (!match || !expected.has(match[1])) continue; + if (!entry.isDirectory()) throw new Error("Shard artifact paths must be directories, not files or symbolic links."); + const attempt = Number(match[2]); + if (!Number.isSafeInteger(attempt) || attempt < 1) throw new Error("Invalid shard artifact attempt."); + const identity = `${match[1]}:${attempt}`; + if (seen.has(identity)) throw new Error(`Duplicate artifact attempt for ${match[1]}.`); + seen.add(identity); + // Artifact availability cannot override the authoritative timeline attempt. + if (timeline?.[match[1]]?.attempt === attempt) { + selected.set(match[1], { shard: match[1], attempt, directory: entry.name }); + } + } + const status = []; + for (const shard of expectedShards) { + const selectedAttempt = selected.get(shard); + if (!selectedAttempt) { + const attempt = timeline?.[shard]?.attempt; + const verified = Number.isSafeInteger(attempt) && attempt > 0; + status.push({ shard, attempt: verified ? attempt : null, complete: false, + reason: verified ? "The latest timeline attempt has no matching artifact." : "The latest timeline attempt could not be verified." }); + continue; + } + const directory = path.join(resultsRoot, selectedAttempt.directory); + const junit = globFiles(directory, "junit/*.junit.xml"); + let complete = false; + let reason = "The artifact is missing valid completion metadata or result files."; + try { + const metadata = JSON.parse(readResultFile(path.join(directory, "shard.json"), MAX_METADATA_BYTES)); + complete = metadata.schemaVersion === 1 && metadata.shard === shard && metadata.attempt === selectedAttempt.attempt && + metadata.complete === true && Number.isSafeInteger(metadata.trials) && metadata.trials > 0 && junit.length > 0; + if (complete) { + const invocation = inspectInvocation(path.join(directory, "results.jsonl")); + complete = invocation.complete && invocation.trials === metadata.trials; + } + if (!complete && metadata.complete === false) reason = "The latest shard attempt did not produce a complete invocation."; + } catch { /* Retain available JUnit below, but do not adopt an incomplete artifact for upload. */ } + if (timeline[shard].complete !== true) { + complete = false; + reason = "The latest timeline job attempt is not complete."; + } + // The Tests tab and Markdown use the SAME selected attempts as the bundle. + // Old-layout JUnit remains diagnostic; old artifacts are never upload-ready. + const files = junit.length ? junit : globFiles(directory, "**/*.junit.xml"); + const output = path.join(selectedRoot, selectedAttempt.directory, "junit"); + fs.mkdirSync(output, { recursive: true }); + for (const [index, file] of files.entries()) { + try { + copyResultFile(file, path.join(output, `${index}.junit.xml`)); + } catch { + complete = false; + reason = "The selected JUnit result files are unreadable or exceed the size limit."; + } + } + status.push({ shard, attempt: selectedAttempt.attempt, complete, reason: complete ? null : reason }); + } + const complete = status.length > 0 && status.every((entry) => entry.complete); + const shardInput = { + schemaVersion: 1, complete, expectedShards, + attempts: [...selected.values()].sort((a, b) => a.shard.localeCompare(b.shard)), + }; + fs.writeFileSync(path.join(resultsRoot, "shard-index.json"), JSON.stringify(shardInput, null, 2) + "\n"); + return { complete, status, shardInput }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const { values } = parseArgs({ options: { "results-root": { type: "string" }, "output-directory": { type: "string" }, + "shard-name": { type: "string" }, attempt: { type: "string" }, "attempts-output": { type: "string" } } }); + if (values["attempts-output"]) { + let result = { schemaVersion: 1, valid: false, attempts: {} }; + try { result = await readShardAttempts(process.env); } + catch { console.log("##vso[task.logissue type=warning]Current build attempts could not be verified; no complete bundle will be published."); } + fs.mkdirSync(path.dirname(path.resolve(values["attempts-output"])), { recursive: true }); + fs.writeFileSync(values["attempts-output"], JSON.stringify(result, null, 2) + "\n"); + } else { + if (!values["results-root"] || !values["output-directory"]) throw new Error("Results and output directories are required."); + const result = stageShardResults({ resultsRoot: values["results-root"], outputDirectory: values["output-directory"], + shardName: values["shard-name"], attempt: Number(values.attempt) }); + console.log(`Staged ${result.shard} attempt ${result.attempt}: ${result.trials} trials; ${result.complete ? "complete" : "incomplete"}.`); + if (!result.complete) console.log(`##vso[task.logissue type=warning]${result.reason}`); + } + } catch (error) { console.error(error.message); process.exitCode = 1; } +} \ No newline at end of file diff --git a/eng/common/scripts/eval/package-lock.json b/eng/common/scripts/eval/package-lock.json index 46258252c6e9..a3b49fce9950 100644 --- a/eng/common/scripts/eval/package-lock.json +++ b/eng/common/scripts/eval/package-lock.json @@ -8,10 +8,28 @@ "name": "@azure-tools/eval-scripts", "version": "1.0.0", "devDependencies": { - "@microsoft/vally-cli": "0.14.0" + "@microsoft/vally-cli": "0.19.0" }, "engines": { - "node": ">=22.12.0" + "node": ">=24.14.1" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.9.0.tgz", + "integrity": "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@azure/abort-controller": { @@ -43,9 +61,9 @@ } }, "node_modules/@azure/core-client": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", - "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -61,6 +79,16 @@ "node": ">=22.0.0" } }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@azure/core-rest-pipeline": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", @@ -123,221 +151,242 @@ } }, "node_modules/@azure/monitor-opentelemetry-exporter": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@azure/monitor-opentelemetry-exporter/-/monitor-opentelemetry-exporter-1.0.0-beta.32.tgz", - "integrity": "sha512-Tk5Tv8KwHhKCQlXET/7ZLtjBv1Zi4lmPTadKTQ9KCURRJWdt+6hu5ze52Tlp2pVeg3mg+MRQ9vhWvVNXMZAp/A==", + "version": "1.0.0-beta.46", + "resolved": "https://registry.npmjs.org/@azure/monitor-opentelemetry-exporter/-/monitor-opentelemetry-exporter-1.0.0-beta.46.tgz", + "integrity": "sha512-DXrkmKi2+zZCZEWPZN2cwEv3++NSeoo93F6FlHivoT+t7Q/7IRxLrCVttn22jGvFs8636cRKcWA8tqS8jPFOsQ==", "dev": true, "license": "MIT", "dependencies": { + "@azure-rest/core-client": "^2.5.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.19.0", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.200.0", - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/sdk-logs": "^0.200.0", - "@opentelemetry/sdk-metrics": "^2.0.0", - "@opentelemetry/sdk-trace-base": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.32.0", + "@azure/core-util": "^1.11.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.221.0", + "@opentelemetry/core": "^2.10.0", + "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-logs": "^0.221.0", + "@opentelemetry/sdk-metrics": "^2.10.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@github/copilot": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.80.tgz", - "integrity": "sha512-6tf93ZF56KOiTTAjK/UhLZkl1W543IzaTQly288kockJZFswpRTnQEI00Yvacpb39DTvTYu3/ha9SeKpo/pgZQ==", + "node_modules/@github/copilot-sdk": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.15.tgz", + "integrity": "sha512-u7WEeF93PKCimIbzv7qgBh1UcuuaHFMoWEDC7W8/2VUGB9eeMN4Ff4QqgHWYHZSQnK5xGtY9Tx8KdyssU9kTYw==", "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "license": "MIT", "dependencies": { - "detect-libc": "^2.1.2" + "koffi": "3.2.1", + "vscode-jsonrpc": "8.2.1", + "zod": "4.3.6" }, - "bin": { - "copilot": "npm-loader.js" + "engines": { + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.80", - "@github/copilot-darwin-x64": "1.0.80", - "@github/copilot-linux-arm64": "1.0.80", - "@github/copilot-linux-x64": "1.0.80", - "@github/copilot-linuxmusl-arm64": "1.0.80", - "@github/copilot-linuxmusl-x64": "1.0.80", - "@github/copilot-win32-arm64": "1.0.80", - "@github/copilot-win32-x64": "1.0.80" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.80.tgz", - "integrity": "sha512-fzn4PnSx3+O/a3ip72KVsjnzORsEygK+0i21bFAnFBYS+0Wi1Pk+o/CmNsJ7aRbf1enSJrcH8UDVkyc9pMGEBg==", + "@github/copilot-sdk-darwin-arm64": "1.0.15", + "@github/copilot-sdk-darwin-x64": "1.0.15", + "@github/copilot-sdk-linux-arm64": "1.0.15", + "@github/copilot-sdk-linux-x64": "1.0.15", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.15", + "@github/copilot-sdk-linuxmusl-x64": "1.0.15", + "@github/copilot-sdk-win32-arm64": "1.0.15", + "@github/copilot-sdk-win32-x64": "1.0.15" + } + }, + "node_modules/@github/copilot-sdk-darwin-arm64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.15.tgz", + "integrity": "sha512-+iZRSgr0Bjc2EksKuNHggfBQR/2HAiLCAjgIx31X4SavNLzcly6BuR/mJLe6xkamlyjj0ugkLK5TKwnmRjqDRg==", "cpu": [ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "license": "MIT", "optional": true, "os": [ "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } + ] }, - "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.80.tgz", - "integrity": "sha512-PKsyGk5DccNzR3bYXcYTGB9N6sHzhzGqEwq/2t1qBwqPbrC98Zo2dOT2G40/QYpJ4XdrGmTmdmfPJQ9PJknlIQ==", + "node_modules/@github/copilot-sdk-darwin-x64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.15.tgz", + "integrity": "sha512-zNtblCoNiSOK0B0y5Y6GvVLfe9PNexJjy/3t1HfcTAA2vHt/zzS440yoGFdqg196Vjtp9yxpLc92Sf1yz6GDMA==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "license": "MIT", "optional": true, "os": [ "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } + ] }, - "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.80.tgz", - "integrity": "sha512-8oXwN2luyHEjIoSk8AkATBjXDhRoQtuiUvC93GpfQKFHI+I1eoOVwIsAq5fKP8jNCF2rOrYFIcTjwmRt38kCcQ==", + "node_modules/@github/copilot-sdk-linux-arm64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.15.tgz", + "integrity": "sha512-Q+vp0Y8DBVf9VIe/OxH6jbgf/16xMbvRI/jsglZFGW+u8YNkUGDcpDxSdonMsE5EplC3Hc6E0xFkjVp6pkV1cg==", "cpu": [ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } + ] }, - "node_modules/@github/copilot-linux-x64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.80.tgz", - "integrity": "sha512-qv1ytVNwA3IDK7kcQow+fAikD67t42+AQ8X42bK/7oudNiv4frVZMO0yh1DYIebVRcmEhmPvbVPY/ptVUK3cbA==", + "node_modules/@github/copilot-sdk-linux-x64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.15.tgz", + "integrity": "sha512-OzZm8sKiUNDbc5GVtj9UY3uf/Ci4ZCSdd76yhguoTXHWE6azV50DycWuSFN8WjmILvemYYcIXo81gXrKcpasPQ==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } + ] }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.80.tgz", - "integrity": "sha512-Qjyi+OlVnPC4Lkuy7blDMMwMUQI/yELl7gDnqQlaN8TEbhZqZueuf3p0a+kEjXcNsw4XtNYQc0eMJqSIYy/Pjg==", + "node_modules/@github/copilot-sdk-linuxmusl-arm64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.15.tgz", + "integrity": "sha512-13mnqoX3egpxwMnd2EsMOnDSBxXqK4ed1M/eX2ElAEdWRakE5/NV6tdsAjwdvgCnMeq7g8TXZ0n2Di2qamxAIQ==", "cpu": [ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } + ] }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.80.tgz", - "integrity": "sha512-rBg8pugf+5FhiZxi2zkOr+rlcOVF6Xg63j1FvryfwPT4DJ2w5Na7O3lpS4sgu8QmsP5H+dAqjlXYLYsvSoVQ0g==", + "node_modules/@github/copilot-sdk-linuxmusl-x64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.15.tgz", + "integrity": "sha512-zKDpqjRsYNBCS+sjZ1nIgZ+4u3sGx6Z6fNIxR8cnJZkqzkdKfjWQ4E/5gqdS+jl+7Eb6eKi3ZMioH9OCFTpjBA==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.11.tgz", - "integrity": "sha512-ngrnfa9052fLTOMoY0iiQS2B6pFDYJpWNj3syCdjzdje0R5mWoij9b8exJZciLvX7BbJjKz2/lIdwo24av3e3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@github/copilot": "^1.0.79", - "koffi": "^3.1.0", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.80.tgz", - "integrity": "sha512-+f7Vkd3vt2DYOxRnS8dStvYu3DY638N/AuLuIjxZp1F9GgwCUZK69wspqIxg2L59PmRRQcH4AGTrRDR60ENIZA==", + "node_modules/@github/copilot-sdk-win32-arm64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.15.tgz", + "integrity": "sha512-m3EE2sdqY+XTI1O8sauMSg80iTVEjUqtD1hc0MGPchQd7mt5m/P7KxN7pmUCQ4oVwCqigiMMKbebTRvOz0j0pA==", "cpu": [ "arm64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "license": "MIT", "optional": true, "os": [ "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } + ] }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.80", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.80.tgz", - "integrity": "sha512-PO0kPqhRTWQfsqGaj4UN3cj8ttkcJYy4wmXiArtFm+03AIFu8xTvuhQDPn2xEOsUome7m7t2XomKoavcrCcRsw==", + "node_modules/@github/copilot-sdk-win32-x64": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.15.tgz", + "integrity": "sha512-BRBJ8za6VZDy+LLRPZVkXWepXgT9psys9WM19d8X7CAl9ymr6Pj7K0nOgJ2eqFMeURXW/tithFgOoxQXNBHSzg==", "cpu": [ "x64" ], "dev": true, - "license": "SEE LICENSE IN LICENSE.md", + "license": "MIT", "optional": true, "os": [ "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" + ] + }, + "node_modules/@github/copilot-sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", "dev": true, "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.2.1.tgz", + "integrity": "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.2.1.tgz", + "integrity": "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" } }, "node_modules/@koromix/koffi-darwin-arm64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.5.tgz", - "integrity": "sha512-IpqITl2fJi3QN9bTtNnygWPdK7ScSjw3xtGu8e6feYGvimCysu+spgI5KyeslY2jTnqxGS9xr8pLAbLhGJ8edA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.2.1.tgz", + "integrity": "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==", "cpu": [ "arm64" ], @@ -352,9 +401,9 @@ } }, "node_modules/@koromix/koffi-darwin-x64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.5.tgz", - "integrity": "sha512-4Tia4BS5EV/+vN9eIrdToanVe+U/2VqTZCBgOzoUbPKjgky51eqM+3J4qdRUvmYJohcJNPob5/hsxeItUZrl1g==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.2.1.tgz", + "integrity": "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==", "cpu": [ "x64" ], @@ -369,9 +418,9 @@ } }, "node_modules/@koromix/koffi-freebsd-arm64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.5.tgz", - "integrity": "sha512-bP94uzseFO79NG3flpU3WxfyvltD+jzC/kN8FDZLi8J0VUZNW1Wmu8yq87KEzjX1qT9KyX4Y+elVbGqHXHTv2Q==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.2.1.tgz", + "integrity": "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==", "cpu": [ "arm64" ], @@ -386,9 +435,9 @@ } }, "node_modules/@koromix/koffi-freebsd-ia32": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.5.tgz", - "integrity": "sha512-raFXXAPHzvCQWhaoMUF+Cc2ZWgg2UBU0RVoowHZhaw9nQYPC1pERcPRH+JA+SNIN6g4d2GFW6uPFc+QbUhsagA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.2.1.tgz", + "integrity": "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==", "cpu": [ "ia32" ], @@ -403,9 +452,9 @@ } }, "node_modules/@koromix/koffi-freebsd-x64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.5.tgz", - "integrity": "sha512-h6RyBZmPMBIDWTABkJIhzDdYwSnYAJvTacHpEjbT55Arkmw1H15Rl7CFtXuEuBrqh+uivoCrRgA6vszl9CsJ9g==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.2.1.tgz", + "integrity": "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==", "cpu": [ "x64" ], @@ -419,10 +468,27 @@ "url": "https://liberapay.com/Koromix" } }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.2.1.tgz", + "integrity": "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@koromix/koffi-linux-arm64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.5.tgz", - "integrity": "sha512-u0vCmKPu4yQDhl/ri1J6U3vDnvYtYjoZaIWb+oMbRXhVZeiqdE53MGPb+q2A7Dj2n9IbYloAfEICQbL6l0pmiQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.2.1.tgz", + "integrity": "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==", "cpu": [ "arm64" ], @@ -437,9 +503,9 @@ } }, "node_modules/@koromix/koffi-linux-ia32": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.5.tgz", - "integrity": "sha512-Xa5JbumWglwPVZgrJcLhqyC1wCWlfm7+C00p3FuOTNGp0qoYuf2/tOoAh0/q7+taVm30cMopu/6lRHwdmF6I/w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.2.1.tgz", + "integrity": "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==", "cpu": [ "ia32" ], @@ -454,9 +520,9 @@ } }, "node_modules/@koromix/koffi-linux-loong64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.5.tgz", - "integrity": "sha512-F3i2CeTcqVBUQiSRUBSEzX1VgtXmLLiZb/ouZtXHkWpTLhJNd9TH7s3CizTofci0VRqlKdexGUYhK5vzPDAAHA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.2.1.tgz", + "integrity": "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==", "cpu": [ "loong64" ], @@ -471,9 +537,9 @@ } }, "node_modules/@koromix/koffi-linux-riscv64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.5.tgz", - "integrity": "sha512-2TgQuzy+4PfDg+rw3kOmN6lywEWdzKT3eaLPbOp0b/9DaN7CLBJ/QIR5GhGsMNpeI30zU0YzFeYFxoVoJ/LqFw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.2.1.tgz", + "integrity": "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==", "cpu": [ "riscv64" ], @@ -488,9 +554,9 @@ } }, "node_modules/@koromix/koffi-linux-x64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.5.tgz", - "integrity": "sha512-2yaIg/1V0m4CiAUMzG4CIlWmq1WJ+QBMlFfaGr9su+OH5fuIqC7V3BbMiB03IwIl1VofIZO5JA4Db4lID8tpbw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.2.1.tgz", + "integrity": "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==", "cpu": [ "x64" ], @@ -505,9 +571,9 @@ } }, "node_modules/@koromix/koffi-openbsd-ia32": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.5.tgz", - "integrity": "sha512-8/OXd+u9omMooykhvdJPEP7u6FFzzrrFo9gOmSHc9/DPt3XkVYOtSsE97PDk6zYaAzwIYHSKjHvIXsfFwFc7sg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.2.1.tgz", + "integrity": "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==", "cpu": [ "ia32" ], @@ -522,9 +588,9 @@ } }, "node_modules/@koromix/koffi-openbsd-x64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.5.tgz", - "integrity": "sha512-SpeqldKkuDk2aTj5PVWumy7eq6Tr2GtBPAOI1NiDHhg8xe433KraxrA9V9UjEd+1+kSGJQGR04nQByN3MPA9PQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.2.1.tgz", + "integrity": "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==", "cpu": [ "x64" ], @@ -539,9 +605,9 @@ } }, "node_modules/@koromix/koffi-win32-arm64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.5.tgz", - "integrity": "sha512-uej3YAEKAhlfVPoIo5sOwtxhTLRVJ01LgtWrKGpnnAQU3C+Ilmaxdh+Oc2xc1G3NK30N5eCVJpyO9r3pjKC6Vw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.2.1.tgz", + "integrity": "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==", "cpu": [ "arm64" ], @@ -556,9 +622,9 @@ } }, "node_modules/@koromix/koffi-win32-ia32": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.5.tgz", - "integrity": "sha512-d42jv2f4PwtJGNJS19Xfn/BRtGsBNNVkw0O0K5tkIGI+yNq4MnPTSUsaGbDIkCLNsCgk/LFqAaE8BExyCdrujA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.2.1.tgz", + "integrity": "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==", "cpu": [ "ia32" ], @@ -573,9 +639,9 @@ } }, "node_modules/@koromix/koffi-win32-x64": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.5.tgz", - "integrity": "sha512-Pyo1WEHEP6Ek2NEn2pquwJzSPLOdY4vymoPSz0an1DgvFWSkOyBYYkGVoxL1ajfj5I1pPdXysYqixtVTzAtOfQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.2.1.tgz", + "integrity": "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==", "cpu": [ "x64" ], @@ -590,65 +656,119 @@ } }, "node_modules/@microsoft/vally": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@microsoft/vally/-/vally-0.14.0.tgz", - "integrity": "sha512-a3Xhj5PUSp6vv38ViSOocrYneMuBu9HpJQ2/VAyoIAHYhWklm/IGmGCylOMv+4brMX2aZSEiX3sW2dMIy3q7vA==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@microsoft/vally/-/vally-0.19.0.tgz", + "integrity": "sha512-EtdL8VR9+Yr1gGyRkgwluA52rSuJ/+WXQ7ENvXwRIyezCDUSv2NRYmf9D2qxyQMEt3GSo7P3hl4RnG0tJSAr0w==", "dev": true, "license": "MIT", "dependencies": { - "@github/copilot-sdk": "^1.0.7", + "@github/copilot-sdk": "1.0.15", + "@modelcontextprotocol/sdk": "^1.31.0", "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.10.0", "js-tiktoken": "^1.0.21", "mdast-util-from-markdown": "^2.0.3", - "picomatch": "^4.0.5", - "yaml": "^2.9.0", - "zod": "^4.4.3" + "picomatch": "^4.0.7", + "semver": "^7.8.5", + "yaml": "^2.9.1", + "zod": "^4.6.5" + }, + "bin": { + "vally-docker-runner": "dist/backend/docker/runner.js" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.14.0" } }, "node_modules/@microsoft/vally-cli": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@microsoft/vally-cli/-/vally-cli-0.14.0.tgz", - "integrity": "sha512-jN9ap1aiuRJQDkiPecrFUp3v5r4Lm+l7154CPY+22cdySGl+XfaOAY5Eh9YqkfwFcya5X+U3pJmBBpFMzHuVlg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@microsoft/vally-cli/-/vally-cli-0.19.0.tgz", + "integrity": "sha512-2GJa+AkABGksS6DsEHBTbLseY7trq6ALpbXBO5bCWM1BylNx1Ams+/6J79moWEC2gKKwTgkc6rUDuX3rWy+avA==", "dev": true, "license": "MIT", "dependencies": { - "@azure/monitor-opentelemetry-exporter": "^1.0.0-beta.32", - "@microsoft/vally": "^0.14.0", - "@microsoft/vally-server": "^0.14.0", + "@azure/monitor-opentelemetry-exporter": "^1.0.0-beta.46", + "@microsoft/vally": "^0.19.0", + "@microsoft/vally-server": "^0.19.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", - "@opentelemetry/resources": "^2.10.0", - "@opentelemetry/sdk-trace-base": "^2.10.0", - "@opentelemetry/sdk-trace-node": "^2.10.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", + "@opentelemetry/resources": "^2.11.0", + "@opentelemetry/sdk-trace-base": "^2.11.0", + "@opentelemetry/sdk-trace-node": "^2.11.0", "commander": "^15.0.0" }, "bin": { "vally": "dist/index.js" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.14.0" }, "optionalDependencies": { "@vscode/deviceid": "~0.1.5" } }, "node_modules/@microsoft/vally-server": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@microsoft/vally-server/-/vally-server-0.14.0.tgz", - "integrity": "sha512-uFyeR8s1oHopjWK0GhRRRFbI+hm4BqHWFUTdgA2GdFRiz+xJfhxjMvTDi69WC0tJoq4WuDEglraEg0CG07LodA==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@microsoft/vally-server/-/vally-server-0.19.0.tgz", + "integrity": "sha512-R2gI3A+wvRJjZzXn9+B7QkN6viXOtMObjYs1s//hPFq3XJ2FFdxDUVghB68jkE/M49bwRtHYhY7vsROIHwReoA==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^2.0.12", - "@microsoft/vally": "^0.14.0", - "better-sqlite3": "^13.0.2", - "hono": "^4.13.1" + "@hono/node-server": "^2.1.3", + "@microsoft/vally": "^0.19.0", + "hono": "^4.13.12" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.14.0" + }, + "peerDependencies": { + "better-sqlite3": "^13.0.3" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, "node_modules/@opentelemetry/api": { @@ -662,9 +782,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.200.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz", - "integrity": "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==", + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.221.0.tgz", + "integrity": "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -675,9 +795,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.10.0.tgz", - "integrity": "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.12.0.tgz", + "integrity": "sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -688,9 +808,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", - "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -704,15 +824,15 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.221.0.tgz", - "integrity": "sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ==", + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.222.0.tgz", + "integrity": "sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/otlp-exporter-base": "0.221.0", - "@opentelemetry/otlp-transformer": "0.221.0", - "@opentelemetry/sdk-trace": "2.10.0" + "@opentelemetry/otlp-exporter-base": "0.222.0", + "@opentelemetry/otlp-transformer": "0.222.0", + "@opentelemetry/sdk-trace": "2.11.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -722,14 +842,14 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.221.0.tgz", - "integrity": "sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==", + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.222.0.tgz", + "integrity": "sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/otlp-transformer": "0.221.0" + "@opentelemetry/core": "2.11.0", + "@opentelemetry/otlp-transformer": "0.222.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -738,19 +858,35 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.221.0.tgz", - "integrity": "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==", + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.222.0.tgz", + "integrity": "sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.221.0", - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", - "@opentelemetry/sdk-logs": "0.221.0", - "@opentelemetry/sdk-metrics": "2.10.0", - "@opentelemetry/sdk-trace": "2.10.0" + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-logs": "0.222.0", + "@opentelemetry/sdk-metrics": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -760,9 +896,9 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.221.0.tgz", - "integrity": "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==", + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.222.0.tgz", + "integrity": "sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -772,16 +908,49 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.221.0.tgz", - "integrity": "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==", + "version": "0.222.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.222.0.tgz", + "integrity": "sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.221.0", - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -791,14 +960,31 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.11.0.tgz", + "integrity": "sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, "node_modules/@opentelemetry/resources": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", - "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.10.0", + "@opentelemetry/core": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -809,15 +995,16 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.200.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.200.0.tgz", - "integrity": "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA==", + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.221.0.tgz", + "integrity": "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.200.0", - "@opentelemetry/core": "2.0.0", - "@opentelemetry/resources": "2.0.0" + "@opentelemetry/api-logs": "0.221.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -827,9 +1014,9 @@ } }, "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.0.tgz", - "integrity": "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -843,13 +1030,13 @@ } }, "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.0.tgz", - "integrity": "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.0", + "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -860,14 +1047,14 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.10.0.tgz", - "integrity": "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.12.0.tgz", + "integrity": "sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0" + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -877,14 +1064,14 @@ } }, "node_modules/@opentelemetry/sdk-trace": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", - "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -895,15 +1082,16 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", - "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", + "deprecated": "Use @opentelemetry/sdk-trace package", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", - "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -913,28 +1101,80 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.10.0.tgz", - "integrity": "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==", + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/sdk-trace": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "2.10.0", - "@opentelemetry/core": "2.10.0", - "@opentelemetry/sdk-trace-base": "2.10.0" + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.12.0.tgz", + "integrity": "sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.12.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/sdk-trace-base": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -976,9 +1216,9 @@ "license": "MIT" }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz", - "integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "dev": true, "license": "MIT", "dependencies": { @@ -1003,6 +1243,20 @@ "uuid": "^14.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1013,6 +1267,41 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1034,18 +1323,84 @@ ], "license": "MIT" }, - "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.0.0" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">=22" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/character-entities": { @@ -1069,6 +1424,83 @@ "node": ">=22.12.0" } }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1101,6 +1533,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1111,16 +1553,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1135,165 +1567,645 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=14.14" + "node": ">= 0.4" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, - "license": "ISC", - "optional": true + "license": "MIT" }, - "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", "engines": { - "node": ">=16.9.0" + "node": ">= 0.8" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, "engines": { - "node": ">= 14" + "node": ">= 0.4" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "es-errors": "^1.3.0" }, "engines": { - "node": ">= 14" + "node": ">= 0.4" } }, - "node_modules/js-tiktoken": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", - "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, "license": "MIT", - "dependencies": { - "base64-js": "^1.5.1" + "engines": { + "node": ">= 0.6" } }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "universalify": "^2.0.0" + "eventsource-parser": "^3.0.1" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/koffi": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.5.tgz", - "integrity": "sha512-XVwwrxg0Ca6IEUQF4YtGIU4XN0LSselFYpYvgfhh8wafCunhEEx5hPr7LZhp5QyeFA/LcRsKHTqncCdjWjWAlg==", + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - }, - "optionalDependencies": { - "@koromix/koffi-darwin-arm64": "3.1.5", - "@koromix/koffi-darwin-x64": "3.1.5", - "@koromix/koffi-freebsd-arm64": "3.1.5", - "@koromix/koffi-freebsd-ia32": "3.1.5", - "@koromix/koffi-freebsd-x64": "3.1.5", - "@koromix/koffi-linux-arm64": "3.1.5", - "@koromix/koffi-linux-ia32": "3.1.5", - "@koromix/koffi-linux-loong64": "3.1.5", - "@koromix/koffi-linux-riscv64": "3.1.5", - "@koromix/koffi-linux-x64": "3.1.5", - "@koromix/koffi-openbsd-ia32": "3.1.5", - "@koromix/koffi-openbsd-x64": "3.1.5", - "@koromix/koffi-win32-arm64": "3.1.5", - "@koromix/koffi-win32-ia32": "3.1.5", - "@koromix/koffi-win32-x64": "3.1.5" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/express" } }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", "dev": true, "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/express" } }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "funding": [ + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/koffi": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.2.1.tgz", + "integrity": "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.2.1", + "@koromix/koffi-android-x64": "3.2.1", + "@koromix/koffi-darwin-arm64": "3.2.1", + "@koromix/koffi-darwin-x64": "3.2.1", + "@koromix/koffi-freebsd-arm64": "3.2.1", + "@koromix/koffi-freebsd-ia32": "3.2.1", + "@koromix/koffi-freebsd-x64": "3.2.1", + "@koromix/koffi-linux-arm": "3.2.1", + "@koromix/koffi-linux-arm64": "3.2.1", + "@koromix/koffi-linux-ia32": "3.2.1", + "@koromix/koffi-linux-loong64": "3.2.1", + "@koromix/koffi-linux-riscv64": "3.2.1", + "@koromix/koffi-linux-x64": "3.2.1", + "@koromix/koffi-openbsd-ia32": "3.2.1", + "@koromix/koffi-openbsd-x64": "3.2.1", + "@koromix/koffi-win32-arm64": "3.2.1", + "@koromix/koffi-win32-ia32": "3.2.1", + "@koromix/koffi-win32-x64": "3.2.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "dev": true, + "funding": [ { "type": "GitHub Sponsors", "url": "https://github.com/sponsors/unifiedjs" @@ -1315,6 +2227,7 @@ "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", @@ -1325,9 +2238,241 @@ } }, "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", "dev": true, "funding": [ { @@ -1341,28 +2486,13 @@ ], "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-factory-destination": { + "node_modules/micromark-util-decode-string": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", "dev": true, "funding": [ { @@ -1376,15 +2506,16 @@ ], "license": "MIT", "dependencies": { + "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", "dev": true, "funding": [ { @@ -1398,16 +2529,13 @@ ], "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-factory-space": { + "node_modules/micromark-util-encode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "dev": true, "funding": [ { @@ -1419,16 +2547,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } + "license": "MIT" }, - "node_modules/micromark-factory-title": { + "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", "dev": true, "funding": [ { @@ -1440,18 +2564,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } + "license": "MIT" }, - "node_modules/micromark-factory-whitespace": { + "node_modules/micromark-util-normalize-identifier": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", "dev": true, "funding": [ { @@ -1465,16 +2583,13 @@ ], "license": "MIT", "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", "dev": true, "funding": [ { @@ -1488,14 +2603,13 @@ ], "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-util-chunked": { + "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "dev": true, "funding": [ { @@ -1509,13 +2623,15 @@ ], "license": "MIT", "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", "dev": true, "funding": [ { @@ -1529,15 +2645,16 @@ ], "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-util-combine-extensions": { + "node_modules/micromark-util-symbol": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "dev": true, "funding": [ { @@ -1549,16 +2666,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } + "license": "MIT" }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", "dev": true, "funding": [ { @@ -1570,215 +2683,456 @@ "url": "https://opencollective.com/unified" } ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" } }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT" }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-types": "^2.0.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], "license": "MIT", "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/node-addon-api": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", - "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { - "node": "^18 || ^20 || >= 21" + "node": ">= 0.8" } }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=0.6" } }, "node_modules/tslib": { @@ -1788,6 +3142,39 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -1813,10 +3200,20 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuid": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", - "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -1828,6 +3225,16 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", @@ -1838,10 +3245,33 @@ "node": ">=14.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { @@ -1855,14 +3285,24 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/eng/common/scripts/eval/package.json b/eng/common/scripts/eval/package.json index b0473fcf8061..89cfac379ca6 100644 --- a/eng/common/scripts/eval/package.json +++ b/eng/common/scripts/eval/package.json @@ -5,12 +5,12 @@ "description": "Vally eval CI glue scripts (matrix sharding, shard runner, JUnit summary) plus the pinned @microsoft/vally-cli the shards install from the committed lockfile. Synced via eng/common; not published.", "type": "module", "engines": { - "node": ">=22.12.0" + "node": ">=24.14.1" }, "scripts": { "test": "node --experimental-strip-types --test" }, "devDependencies": { - "@microsoft/vally-cli": "0.14.0" + "@microsoft/vally-cli": "0.19.0" } } diff --git a/eng/common/scripts/eval/publisher/CHANGELOG.md b/eng/common/scripts/eval/publisher/CHANGELOG.md new file mode 100644 index 000000000000..bb351914a2ac --- /dev/null +++ b/eng/common/scripts/eval/publisher/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## Unreleased + +- Allow opted-in Azure-owned repositories to use the shared publisher and storage-egress policy without a per-repo allowlist, preserving private `-pr` exclusions, trusted-run checks, owner authorization and completeness checks. +- Exclude older JUnit from summaries and selected test files when it does not match the latest verified timeline attempt; keep missing results incomplete. +- Keep shared publisher tests independent of Tools-only CI/Pester/redirect files, while retaining those checks in Tools and testing consumer copies. +- Pass the create-only wildcard as one `--if-none-match=*` argument so Linux cannot expand it into working-directory file names. +- Disable Azure CLI upload progress and parse only stdout so native stderr cannot corrupt a successful JSON result. +- Preserve safe upload/fallback exit codes and known storage error codes without logging CLI response bodies, tokens or request details. +- Exercise the actual Azure CLI against a localhost-only storage fixture, with a bounded startup deadline, synthetic parser diagnostics and no Azure credentials or model evaluations. +- Align entrypoint tests with the existing GitHub App migration instead of expecting the removed PAT variable group. +- Retain one complete, latest-attempt evaluation archive per build in Blob storage. +- Use authenticated Azure CLI publication instead of a custom Node Azure SDK transport. +- Read the storage account from pipeline-owned configuration and keep publishing policy in YAML. +- Pass archive identity explicitly and isolate token-bearing timeline verification from summary rendering. +- Preserve create-only uploads, checksum/provenance metadata and authenticated notification recovery. +- Run shared/archive Node tests and tagged publisher PowerShell tests in the existing Windows/Linux PR CI job, without evaluations. +- Treat real redirect exceptions as permanent and reject noncanonical collision timestamps. +- Read the dashboard refresh URL and Entra API resource from protected pipeline variables, validating both before acquiring notification credentials. +- Preserve uploaded archives when notification configuration is missing or invalid, and handle multiple Node installations in hosted CI regression tests. +- Remove optional local publication status files; log confirmed uploads before attempting warning-only dashboard notifications. +- Use Node 24.14.1+ and pinned pnpm 12.6.0 for the publisher, with a portable SHA512 lock and frozen installs isolated from consuming workspaces. +- Enforce required entries and byte/trial/entry budgets during construction, including merged JSONL, instead of decoding the new ZIP again. +- Validate exactly one Summary-selected attempt per expected shard and parse JSONL strings directly without repeated Buffer conversions. diff --git a/eng/common/scripts/eval/publisher/Publish-EvalResults.ps1 b/eng/common/scripts/eval/publisher/Publish-EvalResults.ps1 new file mode 100644 index 000000000000..3964c687edf8 --- /dev/null +++ b/eng/common/scripts/eval/publisher/Publish-EvalResults.ps1 @@ -0,0 +1,156 @@ +# Uploads one saved archive with Azure CLI, then signals the dashboard without local status files. +#requires -Version 7.4 +[CmdletBinding()] +param( + [Parameter(Mandatory)] [ValidatePattern('^[a-z0-9]{3,24}$')] [string] $StorageAccountName, + [Parameter(Mandatory)] [string] $BundlePath, + [string] $DashboardRefreshUrl = '', + [string] $DashboardResource = '' +) + +Set-StrictMode -Version 4 +$ErrorActionPreference = 'Stop' + +function Invoke-AzJson([string[]] $Arguments) { + # Capture all CLI output, especially tokens and errors; never echo raw Azure responses. + $output = az @Arguments --only-show-errors --output json 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + $code = 'cli_failed' + # Only fixed error codes may leave this boundary, never CLI messages or request data. + $match = [regex]::Match(($output -join "`n"), '(?:\(|ErrorCode:\s*)(AuthorizationPermissionMismatch|AuthorizationFailure|AuthenticationFailed|BlobNotFound|ContainerNotFound|BlobAlreadyExists|ConditionNotMet|InvalidAuthenticationInfo|InvalidHeaderValue|InvalidMetadata|ResourceNotFound)(?:\)|\s|$)') + if ($match.Success) { $code = $match.Groups[1].Value } + $failure = [InvalidOperationException]::new('Azure CLI operation failed.') + $failure.Data['CliFailure'] = "exit=$exitCode, code=$code" + throw $failure + } + # ConvertFrom-Json auto-converts ISO strings into dates on some PowerShell versions. + # Keep Blob metadata strings exact, including their required UTC Z suffix. + # Native stderr is captured as ErrorRecord objects; progress/warnings are not JSON. + $stdout = @($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n" + try { $document = [System.Text.Json.JsonDocument]::Parse($stdout) } + catch { + $failure = [InvalidOperationException]::new('Azure CLI returned invalid JSON.') + $failure.Data['CliFailure'] = 'exit=0, code=invalid_cli_json' + throw $failure + } + try { return $document.RootElement.Clone() } finally { $document.Dispose() } +} + +$operation = 'validate_inputs' +$failureCode = 'invalid_bundle' +$stored = $false +$uploadFailure = $null +try { + $BundlePath = [System.IO.Path]::GetFullPath($BundlePath) + $archive = Get-Item -LiteralPath $BundlePath + $descriptor = Get-Item -LiteralPath "$BundlePath.json" + if ($archive.PSIsContainer -or $archive.LinkType -or $archive.Length -le 0 -or $archive.Length -gt 32MB -or + $descriptor.PSIsContainer -or $descriptor.LinkType -or $descriptor.Length -gt 64KB) { throw 'Invalid saved archive.' } + $prepared = Get-Content -LiteralPath $descriptor.FullName -Raw | ConvertFrom-Json -AsHashtable + $hash = (Get-FileHash -LiteralPath $BundlePath -Algorithm SHA256).Hash.ToLowerInvariant() + $name = [string] $prepared.blobName + if ($prepared.bytes -ne $archive.Length -or $prepared.sha256 -cne $hash -or $name.Length -gt 800 -or + $name -cnotmatch '^v1/[a-z0-9][a-z0-9-]{0,99}/(?:[a-z0-9_.~-]|%[0-9A-F]{2})+/[1-9][0-9]{0,19}/[1-9][0-9]{0,19}/[1-9][0-9]*/dashboard-bundle\.zip$') { + throw 'Archive identity or checksum differs from its preparation descriptor.' + } + + # Deployment values belong to protected pipeline configuration, not queue-time input. + # Never acquire a notification token for a noncanonical or non-App-Service destination. + $refreshUri = $null + $notificationValid = $DashboardRefreshUrl.Length -le 2048 -and + [Uri]::TryCreate($DashboardRefreshUrl, [UriKind]::Absolute, [ref]$refreshUri) -and + $refreshUri.Scheme -ceq 'https' -and $refreshUri.IsDefaultPort -and + $refreshUri.Host -match '^[a-z0-9][a-z0-9.-]*\.azurewebsites\.net$' -and + !$refreshUri.UserInfo -and !$refreshUri.Query -and !$refreshUri.Fragment -and + $refreshUri.AbsolutePath -ceq '/api/refresh' -and $refreshUri.AbsoluteUri -ceq $DashboardRefreshUrl -and + $DashboardResource -cmatch '^api://[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}\z' + + $operation = 'acquire_storage_identity' + $failureCode = 'storage_identity' + $token = (Invoke-AzJson -Arguments @('account', 'get-access-token', '--resource', 'https://storage.azure.com/')).GetProperty('accessToken').GetString() + $payload = $token.Split('.')[1].Replace('-', '+').Replace('_', '/') + $claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload.PadRight([int]([Math]::Ceiling($payload.Length / 4) * 4), '='))) | ConvertFrom-Json -AsHashtable + foreach ($key in @('tid', 'oid')) { + if ($claims[$key] -notmatch '^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$') { throw 'Invalid publisher identity.' } + } + $owner = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($claims.tid):$($claims.oid)"))).ToLowerInvariant() + $storedAt = [DateTime]::UtcNow.ToString('o') + $storage = @('--account-name', $StorageAccountName, '--container-name', 'vally-results', '--name', $name, '--auth-mode', 'login') + + $operation = 'publish_blob' + $failureCode = 'publication_failed' + $duplicate = $false + try { + # CLI owns transport retries. Keep the wildcard in one option=value argument + # so native argument expansion on Linux cannot turn it into local file names. + Invoke-AzJson -Arguments (@('storage', 'blob', 'upload') + $storage + @('--file', $BundlePath, + '--type', 'block', '--overwrite', 'false', '--if-none-match=*', '--no-progress', '--content-type', 'application/zip', + '--metadata', 'schema=1', "sha256=$hash", "publisher=$owner", "storedat=$storedAt", '--timeout', '30', '--socket-timeout', '30')) | Out-Null + } catch { + # Also covers a lost success response: adopt only the same already-stored archive. + $uploadFailure = $_.Exception.Data['CliFailure'] + $operation = 'verify_existing_blob' + $saved = Invoke-AzJson -Arguments (@('storage', 'blob', 'show') + $storage + @('--timeout', '30')) + $failureCode = 'submission_conflict' + $metadata = $saved.GetProperty('metadata') + $savedTime = $metadata.GetProperty('storedat').GetString() + $parsedTime = [DateTimeOffset]::MinValue + # Accept canonical UTC time only; .NET's loose parser otherwise admits spaces + # that the dashboard rejects. Truncate fractions to the reader's millisecond precision. + $validTime = $savedTime -cmatch '^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?Z\z' -and + [DateTimeOffset]::TryParse(($savedTime -replace '(\.\d{3})\d+(?=Z\z)', '$1'), [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsedTime) -and + $parsedTime.UtcDateTime.ToString('yyyy-MM-dd') -ceq $savedTime.Substring(0, 10) + if ($saved.GetProperty('properties').GetProperty('contentLength').GetInt64() -ne $archive.Length -or $metadata.GetProperty('schema').GetString() -cne '1' -or + $metadata.GetProperty('sha256').GetString() -cne $hash -or $metadata.GetProperty('publisher').GetString() -cne $owner -or !$validTime) { throw 'Conflicting archive.' } + $duplicate = $true + } + + $stored = $true + Write-Host "Result archive stored: $name (duplicate: $duplicate)." + + $body = @{ blobName = $name; sha256 = $hash } | ConvertTo-Json -Compress + if ([System.Text.Encoding]::UTF8.GetByteCount($body) -gt 2048) { throw 'Invalid refresh signal.' } + $notificationSucceeded = $false + for ($attempt = 0; $notificationValid -and $attempt -lt 4; $attempt++) { + $status = 0; $headers = @{} + try { + $token = (Invoke-AzJson -Arguments @('account', 'get-access-token', '--resource', $DashboardResource)).GetProperty('accessToken').GetString() + $response = Invoke-WebRequest -Uri $refreshUri ` + -Method Post -ContentType 'application/json' -Body $body -Headers @{ Authorization = "Bearer $token" } ` + -MaximumRedirection 0 -TimeoutSec 180 -OperationTimeoutSeconds 180 -SkipHttpErrorCheck + $status = [int]$response.StatusCode; $headers = $response.Headers + if ($status -eq 200) { + $report = $response.Content | ConvertFrom-Json -AsHashtable + if ($report.status -eq 'succeeded' -and $report.failureCount -eq 0) { + $notificationSucceeded = $true; break + } + } + } catch { + # Redirect errors can throw even with SkipHttpErrorCheck. Never retry/follow them. + if ($_.FullyQualifiedErrorId.Split(',')[0] -eq 'MaximumRedirectExceeded') { $status = 302 } + elseif ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + } + if (($status -ne 0 -and $status -ne 200 -and $status -notin @(408, 429, 500, 502, 503, 504)) -or $attempt -eq 3) { break } + $seconds = [Math]::Pow(2, $attempt) + if ($headers['Retry-After']) { + $value = [string]($headers['Retry-After'] | Select-Object -First 1) + $retryDate = [DateTimeOffset]::MinValue; $retrySeconds = 0.0 + if ([double]::TryParse($value, [ref]$retrySeconds) -and [double]::IsFinite($retrySeconds)) { $seconds = $retrySeconds } + elseif ([DateTimeOffset]::TryParse($value, [ref]$retryDate)) { $seconds = ($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds } + } + Start-Sleep -Milliseconds ([int]([Math]::Clamp($seconds, 0, 60) * 1000)) + } + if (!$notificationValid) { Write-Warning 'Archive stored; dashboard notification configuration is missing or invalid. Reload/startup/daily reconciliation can recover it.' } + elseif (!$notificationSucceeded) { Write-Warning 'Archive stored; dashboard refresh failed. Reload/startup/daily reconciliation can recover it.' } + else { Write-Host 'Dashboard refresh succeeded.' } +} catch { + # Raw CLI output and exceptions can contain tokens. Stored data needs no local status file. + if ($stored) { + Write-Warning 'Archive stored; dashboard refresh failed. Reload/startup/daily reconciliation can recover it.' + } else { + $detail = @($(if ($uploadFailure) { "Upload: $uploadFailure." }), + $(if ($_.Exception.Data['CliFailure']) { "Current operation: $($_.Exception.Data['CliFailure'])." })) -join ' ' + throw "Evaluation publication failed: $operation ($failureCode). $detail No raw Azure response was logged." + } +} \ No newline at end of file diff --git a/eng/common/scripts/eval/publisher/bundle.ts b/eng/common/scripts/eval/publisher/bundle.ts new file mode 100644 index 000000000000..a2a06f32e2ce --- /dev/null +++ b/eng/common/scripts/eval/publisher/bundle.ts @@ -0,0 +1,201 @@ +// Schema-v1 wire contract consumed by the read-only Vally dashboard. This module +// packages existing records only; it never executes an evaluation or calls an LLM. +import { createHash } from "node:crypto"; +import { lstat, mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises"; +import { dirname, join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { strToU8, unzipSync, zipSync } from "fflate"; + +export const MAX_ZIP_BYTES = 32 * 1024 * 1024; +export const MAX_EXPANDED_BYTES = 128 * 1024 * 1024; +const MAX_ENTRIES = 10_000; +const MAX_TRIALS = 100_000; + +export class PublicationError extends Error { + constructor(code, message) { super(message); this.code = code; } +} + +function requireValue(condition, message) { + if (!condition) throw new PublicationError("invalid_bundle", message); +} + +function parseJson(text) { + try { return JSON.parse(text); } + catch { throw new PublicationError("invalid_bundle", "Invalid result JSON; raw evaluation content was not logged."); } +} + +export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +export function isUtcTimestamp(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T.*Z$/.test(value)) return false; + const timestamp = Date.parse(value); + // Date.parse normalizes impossible dates; the dashboard rejects that rollover. + return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value.slice(0, 10); +} + +export async function boundedFile(path, maximum = MAX_EXPANDED_BYTES) { + const stat = await lstat(path); + requireValue(stat.isFile() && stat.size > 0 && stat.size <= maximum, "Result input must be a non-empty regular file within the size limit."); + const bytes = await readFile(path); + requireValue(bytes.length === stat.size, "Result input changed while being read."); + return bytes; +} + +export function validateManifest(value) { + requireValue(value?.schemaVersion === 1 && !Array.isArray(value), "A schema-v1 manifest is required."); + for (const key of ["adoOrganization", "adoProject", "repo", "pipeline", "runTimestamp"]) { + requireValue(typeof value[key] === "string" && value[key].trim().length > 0 && value[key].length <= 200 && + value[key] === value[key].trim() && !/[\u0000-\u001f\u007f]/.test(value[key]), + "Manifest identity fields must be bounded, trimmed non-empty strings."); + } + // The reader trims identity fields before deriving its canonical Blob name. + // Reject padding here rather than publishing an archive the reader cannot import. + requireValue(/^[a-z0-9][a-z0-9-]{0,99}$/.test(value.adoOrganization) && + !/[\\/:]/.test(value.adoProject) && !/^\.+$/.test(value.adoProject), "Invalid Azure DevOps organization/project identity."); + for (const key of ["pipelineDefinitionId", "buildId"]) { + requireValue(typeof value[key] === "string" && /^[1-9][0-9]{0,19}$/.test(value[key]), "Positive numeric pipeline/build IDs are required."); + } + requireValue(Number.isSafeInteger(value.summaryAttempt) && value.summaryAttempt > 0, "A positive Summary attempt is required."); + requireValue(isUtcTimestamp(value.runTimestamp), "A valid UTC result timestamp is required."); + if (value.branch !== undefined) requireValue(typeof value.branch === "string" && value.branch.trim().length > 0 && value.branch === value.branch.trim() && + value.branch.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value.branch), "Invalid source branch."); + if (value.sourceVersion !== undefined) requireValue(typeof value.sourceVersion === "string" && /^[a-fA-F0-9]{40,64}$/.test(value.sourceVersion), "A source commit SHA is required."); + return value; +} + +export function blobName(manifest) { + validateManifest(manifest); + const name = ["v1", manifest.adoOrganization, manifest.adoProject.toLowerCase(), manifest.pipelineDefinitionId, + manifest.buildId, String(manifest.summaryAttempt)].map(encodeURIComponent).join("/") + "/dashboard-bundle.zip"; + requireValue(name.length <= 800, "The encoded archive name exceeds the dashboard limit."); + return name; +} + +export function validateSelectedAttempts(index) { + requireValue(index?.schemaVersion === 1 && index.complete === true && Array.isArray(index.expectedShards) && + index.expectedShards.length > 0 && Array.isArray(index.attempts), "Only a complete expected-shard index can be published."); + // Summary already selected the latest attempts against the current-build timeline. + // Accept exactly that selection, not an unselected list that could hide ambiguity. + const expected = new Set(index.expectedShards); + requireValue(expected.size === index.expectedShards.length && [...expected].every(name => typeof name === "string" && + /^[A-Za-z0-9_][A-Za-z0-9_-]{0,199}$/.test(name)), "Expected shard names must be valid and unique."); + requireValue(index.attempts.length === expected.size, "Exactly one selected attempt per expected shard is required."); + for (const item of index.attempts) { + requireValue(item && expected.delete(item.shard) && Number.isSafeInteger(item.attempt) && item.attempt > 0 && + item.directory === `eval-result-${item.shard}-${item.attempt}`, "Invalid shard directory or attempt."); + } + return [...index.attempts].sort((a, b) => a.shard.localeCompare(b.shard)); +} + +function trialsFrom(bytes, { onSkipped } = {}) { + const trials = []; + for (const [line] of Buffer.from(bytes).toString("utf8").matchAll(/[^\n]+/g)) { + if (!line.trim()) continue; + requireValue(Buffer.byteLength(line) <= 8 * 1024 * 1024, "A JSONL record exceeds the size limit."); + const item = parseJson(line); + requireValue(item && typeof item === "object" && !Array.isArray(item), "Invalid trial object."); + if (["log", "run-summary"].includes(item.type)) continue; + if (onSkipped && item.status === "skipped" && !item.experiment && [undefined, "trial-result"].includes(item.type)) { + onSkipped(); continue; + } + requireValue((item.type === undefined || item.type === "trial-result") && !item.experiment && + ["success", "error"].includes(item.status), "Only plain-eval trial records are supported."); + const serialized = JSON.stringify(item); + requireValue(Buffer.byteLength(serialized) <= 8 * 1024 * 1024, "A serialized JSONL record exceeds the size limit."); + trials.push(serialized + "\n"); + requireValue(trials.length <= MAX_TRIALS, "Too many trial records."); + } + requireValue(trials.length > 0, "Every selected shard must contain trial records."); + return trials; +} + +export function validateBundle(bytes) { + requireValue(bytes.length > 0 && bytes.length <= MAX_ZIP_BYTES, "Bundle exceeds the 32 MiB archive limit."); + const sizes = new Map(); let expanded = 0; + try { + unzipSync(bytes, { filter(file) { + requireValue(["manifest.json", "results.jsonl", "eval-summary.md"].includes(file.name) || /^junit\/[a-zA-Z0-9_-]+\.xml$/.test(file.name), "Unexpected ZIP entry."); + requireValue(!sizes.has(file.name.toLowerCase()), "Duplicate ZIP entry."); + requireValue(Number.isSafeInteger(file.originalSize) && file.originalSize >= 0, "Invalid ZIP entry size."); + expanded += file.originalSize; sizes.set(file.name.toLowerCase(), file.originalSize); + requireValue(sizes.size <= MAX_ENTRIES && expanded <= MAX_EXPANDED_BYTES, "Bundle exceeds the extraction budget."); + return false; + } }); + const entries = unzipSync(bytes); + for (const name of ["manifest.json", "results.jsonl", "eval-summary.md"]) requireValue(entries[name]?.length > 0, "Required ZIP entry is missing."); + requireValue(Object.keys(entries).some(name => name.startsWith("junit/")), "JUnit results are missing."); + for (const [name, content] of Object.entries(entries)) requireValue(content.length === sizes.get(name.toLowerCase()), "ZIP size mismatch."); + const manifest = validateManifest(parseJson(Buffer.from(entries["manifest.json"]).toString("utf8"))); + return { manifest, trials: trialsFrom(entries["results.jsonl"]).length, entries }; + } catch (error) { + if (error instanceof PublicationError) throw error; + throw new PublicationError("invalid_bundle", "The saved ZIP could not be validated."); + } +} + +export async function prepareBundle({ indexPath, summaryPath, outputPath, manifest }) { + const name = blobName(manifest); + const root = await realpath(dirname(resolve(indexPath))); + const selected = validateSelectedAttempts(parseJson((await boundedFile(indexPath, 2 * 1024 * 1024)).toString("utf8"))); + const entries = {}, trials = []; + let expanded = 0, trialBytes = 0, entryCount = 0, skipped = 0; + const add = (name, bytes) => { + expanded += bytes.length; + requireValue(bytes.length > 0 && expanded <= MAX_EXPANDED_BYTES && ++entryCount <= MAX_ENTRIES, "Merged results exceed the bundle budget."); + entries[name] = bytes; + }; + add("manifest.json", strToU8(JSON.stringify(manifest, null, 2) + "\n")); + for (const [number, item] of selected.entries()) { + const directory = join(root, item.directory), info = await lstat(directory); + requireValue(info.isDirectory() && (await realpath(directory)).startsWith(root + sep), "Shard directory must remain inside the artifact root."); + const metadata = parseJson((await boundedFile(join(directory, "shard.json"), 64 * 1024)).toString("utf8")); + requireValue(metadata.schemaVersion === 1 && metadata.complete === true && metadata.shard === item.shard && + metadata.attempt === item.attempt, "Selected shard is incomplete or has mismatched metadata."); + // The current dashboard imports executed trials (success/error). Vally's + // executor-incompatible skips remain in raw shard artifacts and JUnit/ + // Markdown, not fabricated as failed executions or silently lost counts. + const shardTrials = trialsFrom(await boundedFile(join(directory, "results.jsonl")), { onSkipped: () => { skipped++; } }); + requireValue(shardTrials.length === metadata.trials, "Shard trial count differs from its completion marker."); + for (const text of shardTrials) { + trialBytes += Buffer.byteLength(text); trials.push(text); + requireValue(expanded + trialBytes <= MAX_EXPANDED_BYTES && trials.length <= MAX_TRIALS, "Merged trials exceed the bundle budget."); + } + const junitRoot = join(directory, "junit"); + requireValue((await lstat(junitRoot)).isDirectory(), "JUnit path must not be a symbolic link."); + const files = (await readdir(junitRoot)).filter(name => name.endsWith(".junit.xml")).sort(); + requireValue(files.length > 0, "A selected shard has no JUnit."); + for (const [fileNumber, name] of files.entries()) add(`junit/${number}-${fileNumber}.xml`, await boundedFile(join(junitRoot, name), MAX_EXPANDED_BYTES - expanded - trialBytes)); + } + add("results.jsonl", strToU8(trials.join(""))); + add("eval-summary.md", await boundedFile(summaryPath, MAX_EXPANDED_BYTES - expanded)); + // Stable timestamps prevent packaging metadata from changing otherwise equal bytes. + const bytes = zipSync(entries, { level: 6, mtime: new Date("2020-01-01T00:00:00Z") }); + // Inputs, required non-empty entries and merged budgets were checked during + // construction. Full ZIP decoding remains an archive-contract test, not a second pass. + requireValue(bytes.length > 0 && bytes.length <= MAX_ZIP_BYTES, "Bundle exceeds the 32 MiB archive limit."); + await mkdir(dirname(resolve(outputPath)), { recursive: true }); + await writeFile(outputPath, bytes, { flag: "wx" }); + return { shards: selected.length, trials: trials.length, skipped, bytes: bytes.length, sha256: sha256(bytes), blobName: name }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const { values } = parseArgs({ options: { index: { type: "string" }, summary: { type: "string" }, output: { type: "string" }, + organization: { type: "string" }, project: { type: "string" }, repository: { type: "string" }, pipeline: { type: "string" }, + "definition-id": { type: "string" }, "build-id": { type: "string" }, attempt: { type: "string" }, + branch: { type: "string" }, "source-version": { type: "string" } } }); + if (!values.index || !values.summary || !values.output) throw new PublicationError("invalid_arguments", "Provide --index, --summary and --output."); + const result = await prepareBundle({ indexPath: values.index, summaryPath: values.summary, outputPath: values.output, + manifest: { schemaVersion: 1, adoOrganization: values.organization, adoProject: values.project, repo: values.repository, + pipeline: values.pipeline, pipelineDefinitionId: values["definition-id"], buildId: values["build-id"], + summaryAttempt: Number(values.attempt), branch: values.branch, sourceVersion: values["source-version"], runTimestamp: new Date().toISOString() } }); + // Transport consumes this saved identity/checksum, not pipeline environment variables. + await writeFile(`${values.output}.json`, JSON.stringify(result, null, 2) + "\n", { flag: "wx" }); + console.log(`Prepared one build bundle: ${result.shards} selected shards, ${result.trials} trials, ${result.bytes} bytes.`); + if (result.skipped) console.log(`${result.skipped} non-executed skipped records are retained in raw artifacts and JUnit/Markdown, not imported as dashboard outcomes.`); + } catch (error) { + console.error(error instanceof PublicationError ? error.message : "Bundle preparation failed; inspect the selected result artifacts."); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/eng/common/scripts/eval/publisher/package.json b/eng/common/scripts/eval/publisher/package.json new file mode 100644 index 000000000000..2b06a64ec4c6 --- /dev/null +++ b/eng/common/scripts/eval/publisher/package.json @@ -0,0 +1,17 @@ +{ + "name": "@azure-tools/eval-result-publisher", + "version": "1.0.0", + "private": true, + "description": "Data-only Vally result bundling; Azure CLI performs publication. No evaluator or dashboard runtime.", + "type": "module", + "packageManager": "pnpm@12.6.0", + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "test": "node --experimental-strip-types --test test/*.test.ts" + }, + "dependencies": { + "fflate": "0.8.3" + } +} \ No newline at end of file diff --git a/eng/common/scripts/eval/publisher/pnpm-lock.yaml b/eng/common/scripts/eval/publisher/pnpm-lock.yaml new file mode 100644 index 000000000000..56c3971ecf94 --- /dev/null +++ b/eng/common/scripts/eval/publisher/pnpm-lock.yaml @@ -0,0 +1,22 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + fflate: + specifier: 0.8.3 + version: 0.8.3 + +packages: + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + +snapshots: + + fflate@0.8.3: {} \ No newline at end of file diff --git a/eng/common/scripts/eval/publisher/test/publisher.test.ts b/eng/common/scripts/eval/publisher/test/publisher.test.ts new file mode 100644 index 000000000000..f18fdf6d5a1a --- /dev/null +++ b/eng/common/scripts/eval/publisher/test/publisher.test.ts @@ -0,0 +1,421 @@ +// Verifies the saved archive contract and pipeline-owned publication policy, without Azure requests. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { cp, mkdtemp, mkdir, readFile, realpath, rm, truncate, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; +import { zipSync, strToU8 } from "fflate"; +import { MAX_EXPANDED_BYTES, MAX_ZIP_BYTES, blobName, boundedFile, isUtcTimestamp, prepareBundle, validateSelectedAttempts, sha256, validateBundle, validateManifest } from "../bundle.ts"; + +const manifest = { schemaVersion: 1, adoOrganization: "azure-sdk", adoProject: "internal", repo: "Azure/azure-sdk-tools", + pipeline: "synthetic", pipelineDefinitionId: "8255", buildId: "1001", summaryAttempt: 1, runTimestamp: "2026-09-21T00:00:00.000Z" }; +const trial = { type: "trial-result", itemId: "synthetic", evalName: "synthetic", stimulus: "synthetic", model: "no-llm", status: "error", error: "Synthetic", durationMs: 1, trajectory: null, gradeResult: null }; +const entries = (extra = {}) => ({ "manifest.json": strToU8(JSON.stringify(manifest)), "results.jsonl": strToU8(JSON.stringify(trial)), + "eval-summary.md": strToU8("# Synthetic test"), "junit/0.xml": strToU8(''), ...extra }); +const zip = (extra) => zipSync(entries(extra), { mtime: new Date("2020-01-01T00:00:00Z") }); + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "eval-publisher-")); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "saved.zip"); await writeFile(path, zip()); + return { root, path }; +} + +async function constructionFixture(t) { + const f = await fixture(t), artifact = join(f.root, "eval-result-a-2"), junit = join(artifact, "junit"); + await mkdir(junit, { recursive: true }); + await writeFile(join(artifact, "results.jsonl"), JSON.stringify(trial) + "\n"); + await writeFile(join(artifact, "shard.json"), JSON.stringify({ schemaVersion: 1, shard: "a", attempt: 2, complete: true, trials: 1 })); + await writeFile(join(junit, "0.junit.xml"), ""); + const indexPath = join(f.root, "shard-index.json"), summaryPath = join(f.root, "summary.md"), outputPath = join(f.root, "bundle.zip"); + await writeFile(indexPath, JSON.stringify({ schemaVersion: 1, complete: true, expectedShards: ["a"], + attempts: [{ shard: "a", attempt: 2, directory: "eval-result-a-2" }] })); + await writeFile(summaryPath, "# Complete build\n"); + return { ...f, artifact, junit, indexPath, summaryPath, outputPath, manifest }; +} + +function run(script, args, env = {}, preload = []) { + const environment = { ...process.env, ...env }; delete environment.NODE_TEST_CONTEXT; + const child = spawnSync(process.execPath, ["--experimental-strip-types", ...preload, script, ...args], { + encoding: "utf8", env: environment, timeout: 30_000, maxBuffer: 2 * 1024 * 1024, + }); + assert.ifError(child.error); assert.equal(child.status, 0, child.stderr + child.stdout); + return child; +} + +test("specs bundles preserve pipeline identity and completed failing evaluations", async (t) => { + const f = await constructionFixture(t); + const specsManifest = { ...manifest, repo: "Azure/azure-rest-api-specs", pipeline: "azure-rest-api-specs - eval-skills", + pipelineDefinitionId: "8286", branch: "refs/heads/main", sourceVersion: "c".repeat(40) }; + const failedTrial = { ...trial, status: "success", error: null, gradeResult: { passed: false } }; + await writeFile(join(f.artifact, "results.jsonl"), JSON.stringify(failedTrial) + "\n"); + await writeFile(join(f.junit, "0.junit.xml"), ''); + + const saved = await prepareBundle({ ...f, manifest: specsManifest }); + const decoded = validateBundle(await readFile(f.outputPath)); + + assert.deepEqual(decoded.manifest, specsManifest); + assert.equal(saved.blobName, "v1/azure-sdk/internal/8286/1001/1/dashboard-bundle.zip"); + assert.equal(saved.shards, 1); assert.equal(decoded.trials, 1); + assert.deepEqual(JSON.parse(Buffer.from(decoded.entries["results.jsonl"]).toString("utf8").trim()), failedTrial); + assert.match(Buffer.from(decoded.entries["junit/0-0.xml"]).toString("utf8"), / saved timeline evidence -> token-free summary -> one schema-v1 ZIP", async (t) => { + const f = await fixture(t), scripts = resolve(import.meta.dirname, "../.."); + const downloads = join(f.root, "downloads"); await mkdir(downloads); + for (const shard of ["shard_a", "shard_b"]) { + const source = join(f.root, shard), invocation = join(source, "invocation"); + await mkdir(invocation, { recursive: true }); + await writeFile(join(invocation, "results.jsonl"), [ + { ...trial, itemId: shard }, { type: "run-summary", evals: [{ name: trial.evalName, stimuliRun: 1, passed: false }] }, + ].map(record => JSON.stringify(record)).join("\n") + "\n"); + await writeFile(join(invocation, "eval-results.junit.xml"), + ``); + run(join(scripts, "lib/shard-results.ts"), ["--results-root", source, "--output-directory", join(downloads, `eval-result-${shard}-1`), "--shard-name", shard, "--attempt", "1"]); + } + const summary = join(f.root, "summary", "eval-summary.md"), attempts = join(f.root, "summary", "job-attempts.json"); + const mock = join(f.root, "timeline-fetch.mjs"); + await writeFile(mock, `import { Socket } from "node:net"; + Socket.prototype.connect = function() { throw new Error("Network disabled"); }; + globalThis.fetch = async (url, options) => { + if (url.origin !== "https://dev.azure.com" || options.headers.authorization !== "Bearer fixture-token" || options.redirect !== "error") throw new Error("Unexpected timeline request"); + return Response.json({ records: ["a", "b"].map(key => ({ type: "Job", identifier: "Eval.RunShard." + key, + attempt: 1, state: "completed", result: "failed" })) }); + };`); + const matrix = JSON.stringify({ a: { shardName: "shard_a" }, b: { shardName: "shard_b" } }); + run(join(scripts, "lib/shard-results.ts"), ["--attempts-output", attempts], { + EVAL_EXPECTED_MATRIX: matrix, SYSTEM_COLLECTIONURI: "https://dev.azure.com/azure-sdk/", + SYSTEM_TEAMPROJECTID: "00000000-0000-4000-8000-000000000001", BUILD_BUILDID: "1001", SYSTEM_ACCESSTOKEN: "fixture-token", + }, ["--import", pathToFileURL(mock).href]); + assert.equal(JSON.parse(await readFile(attempts, "utf8")).valid, true); + const offline = join(f.root, "no-network.mjs"); + await writeFile(offline, `globalThis.fetch = () => { throw new Error("Rendering cannot fetch"); }; delete process.env.SYSTEM_ACCESSTOKEN;`); + const result = run(join(scripts, "build-eval-summary.ts"), ["--results-root", downloads, "--selected-root", join(f.root, "selected"), + "--attempts-file", attempts, "--output-path", summary], { TF_BUILD: "true", EVAL_EXPECTED_MATRIX: matrix }, ["--import", pathToFileURL(offline).href]); + assert.match(result.stdout, /EvalSummaryComplete\]true/); + const output = join(f.root, "build.zip"); + const packed = await prepareBundle({ indexPath: join(downloads, "shard-index.json"), summaryPath: summary, outputPath: output, manifest }); + assert.equal(packed.trials, 2); assert.equal(packed.shards, 2); + assert.equal(packed.sha256, sha256(await readFile(output))); + assert.equal(packed.blobName, "v1/azure-sdk/internal/8255/1001/1/dashboard-bundle.zip"); + const checked = validateBundle(await readFile(output)); assert.deepEqual(checked.manifest, manifest); + assert.equal(Object.keys(checked.entries).length, 5); + assert.doesNotMatch(Buffer.from(checked.entries["results.jsonl"]).toString("utf8"), /run-summary/); + assert.match(Buffer.from(checked.entries["eval-summary.md"]).toString("utf8"), /FAILED/); + await assert.rejects(prepareBundle({ indexPath: join(downloads, "shard-index.json"), summaryPath: summary, outputPath: output, manifest }), { code: "EEXIST" }); +}); + +test("bundle CLI takes explicit metadata, preserves the real pipeline name and saves transport identity", async (t) => { + const f = await fixture(t), artifact = join(f.root, "eval-result-a-1"); + await mkdir(join(artifact, "junit"), { recursive: true }); + await writeFile(join(artifact, "results.jsonl"), JSON.stringify(trial)); + await writeFile(join(artifact, "shard.json"), JSON.stringify({ schemaVersion: 1, shard: "a", attempt: 1, complete: true, trials: 1 })); + await writeFile(join(artifact, "junit", "0.junit.xml"), ''); + const index = join(f.root, "shard-index.json"), summary = join(f.root, "summary.md"), output = join(f.root, "explicit.zip"); + await writeFile(index, JSON.stringify({ schemaVersion: 1, complete: true, expectedShards: ["a"], attempts: [{ shard: "a", attempt: 1, directory: "eval-result-a-1" }] })); + await writeFile(summary, "# Saved summary"); + run(resolve(import.meta.dirname, "../bundle.ts"), ["--index", index, "--summary", summary, "--output", output, + "--organization", "azure-sdk", "--project", "Test Project", "--repository", "Azure/azure-sdk-tools", "--pipeline", "Real Pipeline Name", + "--definition-id", "9999", "--build-id", "1001", "--attempt", "2", "--branch", "refs/heads/main", "--source-version", "a".repeat(40)], { + SYSTEM_COLLECTIONURI: "https://attacker.example", SYSTEM_DEFINITIONID: "bad", BUILD_DEFINITIONNAME: "wrong", + }); + const bytes = await readFile(output), checked = validateBundle(bytes), prepared = JSON.parse(await readFile(`${output}.json`, "utf8")); + assert.equal(checked.manifest.pipeline, "Real Pipeline Name"); assert.equal(checked.manifest.pipelineDefinitionId, "9999"); + assert.equal(prepared.blobName, "v1/azure-sdk/test%20project/9999/1001/2/dashboard-bundle.zip"); + assert.equal(prepared.sha256, sha256(bytes)); assert.equal(prepared.bytes, bytes.length); + assert.doesNotMatch(await readFile(resolve(import.meta.dirname, "../bundle.ts"), "utf8"), /process\.env|SYSTEM_|BUILD_|TF_BUILD/); +}); + +test("skips remain in raw artifacts/JUnit, not fabricated as dashboard executions", async (t) => { + const f = await fixture(t), artifact = join(f.root, "eval-result-a-1"); + await mkdir(join(artifact, "junit"), { recursive: true }); + const skipped = { type: "trial-result", status: "skipped", itemId: "incompatible", skipReason: "Synthetic incompatible executor" }; + await writeFile(join(artifact, "results.jsonl"), [trial, skipped, { type: "run-summary", evals: [{ name: "synthetic" }] }].map(record => JSON.stringify(record)).join("\n")); + await writeFile(join(artifact, "shard.json"), JSON.stringify({ schemaVersion: 1, shard: "a", attempt: 1, complete: true, trials: 1 })); + await writeFile(join(artifact, "junit", "0.junit.xml"), ''); + const index = join(f.root, "shard-index.json"), summary = join(f.root, "summary.md"), output = join(f.root, "mixed.zip"); + await writeFile(index, JSON.stringify({ schemaVersion: 1, complete: true, expectedShards: ["a"], attempts: [{ shard: "a", attempt: 1, directory: "eval-result-a-1" }] })); + await writeFile(summary, "# Mixed result\n1 executed, 1 skipped"); + const result = await prepareBundle({ indexPath: index, summaryPath: summary, outputPath: output, manifest }); + assert.equal(result.trials, 1); assert.equal(result.skipped, 1); + const checked = validateBundle(await readFile(output)); assert.equal(checked.trials, 1); + assert.match(Buffer.from(checked.entries["junit/0-0.xml"]).toString(), / { + const a = { shard: "a", attempt: 2, directory: "eval-result-a-2" }, b = { shard: "b", attempt: 3, directory: "eval-result-b-3" }; + const base = { schemaVersion: 1, complete: true, expectedShards: ["a", "b"], attempts: [b, a] }; + assert.deepEqual(validateSelectedAttempts(base), [a, b]); assert.deepEqual(base.attempts, [b, a]); + for (const source of [{ ...base, complete: false }, { ...base, expectedShards: ["a", "b", "c"] }, { ...base, expectedShards: ["a", "a"] }, + { ...base, attempts: [a] }, { ...base, attempts: [a, a] }, { ...base, attempts: [a, { ...a, attempt: 1, directory: "eval-result-a-1" }, b] }, + ...[0, -1, 1.5, "2"].map(attempt => ({ ...base, attempts: [{ ...a, attempt }, b] })), + { ...base, expectedShards: ["a", "../b"] }, { ...base, attempts: [a, { ...b, shard: "unexpected" }] }, + { ...base, attempts: [{ ...a, directory: "../elsewhere" }, b] }, { ...base, attempts: [null, b] }]) { + assert.throws(() => validateSelectedAttempts(source), { code: "invalid_bundle" }); + } +}); + +test("construction keeps nonempty required inputs and shard completion checks before saving", async (t) => { + for (const input of ["results.jsonl", "junit/0.junit.xml", "summary.md"]) await t.test(`empty ${input}`, async (t) => { + const f = await constructionFixture(t); + await writeFile(input === "summary.md" ? f.summaryPath : join(f.artifact, input), ""); + await assert.rejects(prepareBundle(f), { code: "invalid_bundle" }); + await assert.rejects(readFile(f.outputPath), { code: "ENOENT" }); + }); + for (const patch of [{ complete: false }, { shard: "other" }, { attempt: 1 }, { trials: 2 }]) await t.test(`metadata ${JSON.stringify(patch)}`, async (t) => { + const f = await constructionFixture(t), path = join(f.artifact, "shard.json"); + await writeFile(path, JSON.stringify({ ...JSON.parse(await readFile(path, "utf8")), ...patch })); + await assert.rejects(prepareBundle(f), { code: "invalid_bundle" }); + await assert.rejects(readFile(f.outputPath), { code: "ENOENT" }); + }); + const f = await constructionFixture(t); + await rm(join(f.junit, "0.junit.xml")); + await assert.rejects(prepareBundle(f), { code: "invalid_bundle" }); +}); + +test("construction counts all 10,000 entries including merged JSONL and rejects one more", async (t) => { + const f = await constructionFixture(t); + // 9,997 JUnit files plus manifest, JSONL and Markdown reach the exact limit. + for (let start = 1; start < 9_997; start += 100) { + await Promise.all(Array.from({ length: Math.min(100, 9_997 - start) }, (_, offset) => + writeFile(join(f.junit, `${start + offset}.junit.xml`), ""))); + } + await prepareBundle(f); + assert.equal(Object.keys(validateBundle(await readFile(f.outputPath)).entries).length, 10_000); + await writeFile(join(f.junit, "9997.junit.xml"), ""); + const outputPath = join(f.root, "too-many-entries.zip"); + await assert.rejects(prepareBundle({ ...f, outputPath }), { code: "invalid_bundle" }); + await assert.rejects(readFile(outputPath), { code: "ENOENT" }); +}); + +test("construction enforces the aggregate expanded budget including trials before reading oversized input", async (t) => { + const f = await constructionFixture(t); + const used = Buffer.byteLength(JSON.stringify(manifest, null, 2) + "\n") + Buffer.byteLength(JSON.stringify(trial) + "\n") + + (await readFile(join(f.junit, "0.junit.xml"))).length; + await truncate(f.summaryPath, MAX_EXPANDED_BYTES - used + 1); + await assert.rejects(prepareBundle(f), { code: "invalid_bundle" }); + await assert.rejects(readFile(f.outputPath), { code: "ENOENT" }); +}); + +test("construction keeps the final compressed-size cap without decoding the newly created ZIP", async (t) => { + const f = await constructionFixture(t); + await writeFile(join(f.junit, "0.junit.xml"), randomBytes(MAX_ZIP_BYTES + 1024)); + await assert.rejects(prepareBundle(f), { code: "invalid_bundle" }); + await assert.rejects(readFile(f.outputPath), { code: "ENOENT" }); + const source = await readFile(resolve(import.meta.dirname, "../bundle.ts"), "utf8"); + assert.doesNotMatch(source.slice(source.indexOf("export async function prepareBundle")), /validateBundle\(|unzipSync\(/); +}); + +test("construction permits 100,000 trials but rejects a merged build with more", async (t) => { + const f = await constructionFixture(t), record = '{"status":"error"}\n'; + const metadata = { schemaVersion: 1, shard: "a", attempt: 2, complete: true, trials: 100_000 }; + await writeFile(join(f.artifact, "results.jsonl"), record.repeat(100_000)); + await writeFile(join(f.artifact, "shard.json"), JSON.stringify(metadata)); + const result = await prepareBundle(f); assert.equal(result.trials, 100_000); + assert.equal(validateBundle(await readFile(f.outputPath)).trials, 100_000); + const other = join(f.root, "eval-result-b-1"); await mkdir(join(other, "junit"), { recursive: true }); + await writeFile(join(other, "results.jsonl"), record); + await writeFile(join(other, "shard.json"), JSON.stringify({ ...metadata, shard: "b", attempt: 1, trials: 1 })); + await writeFile(join(other, "junit/0.junit.xml"), ""); + const index = JSON.parse(await readFile(f.indexPath, "utf8")); + index.expectedShards.push("b"); index.attempts.push({ shard: "b", attempt: 1, directory: "eval-result-b-1" }); + await writeFile(f.indexPath, JSON.stringify(index)); + const outputPath = join(f.root, "too-many-trials.zip"); + await assert.rejects(prepareBundle({ ...f, outputPath }), { code: "invalid_bundle" }); + await assert.rejects(readFile(outputPath), { code: "ENOENT" }); +}); + +test("direct JSON string parsing preserves UTF-8, byte limits and sanitized errors", async (t) => { + const f = await constructionFixture(t), path = join(f.artifact, "results.jsonl"); + await writeFile(path, JSON.stringify({ ...trial, itemId: "UTF-8 界" }) + "\r\n"); + await prepareBundle(f); + assert.match(Buffer.from(validateBundle(await readFile(f.outputPath)).entries["results.jsonl"]).toString("utf8"), /UTF-8 界/); + for (const raw of [JSON.stringify({ status: "error", itemId: "界".repeat(Math.ceil(8 * 1024 * 1024 / 3)) }), + '{"status":"error","values":[' + Array(940_000).fill("1e-6").join(",") + "]}"]) { + await writeFile(path, raw); + await assert.rejects(prepareBundle({ ...f, outputPath: join(f.root, "oversized-record.zip") }), { code: "invalid_bundle" }); + } + for (const path of [f.indexPath, join(f.artifact, "shard.json"), join(f.artifact, "results.jsonl")]) { + const previous = await readFile(path); + await writeFile(path, '{"secret":"PRIVATE_RESULT_CONTENT"'); + await assert.rejects(prepareBundle(f), error => error.code === "invalid_bundle" && !error.message.includes("PRIVATE_RESULT_CONTENT")); + await writeFile(path, previous); + } +}); + +test("ZIP allowlist, corrupt records, experiments and extraction bombs are rejected without logging content", async (t) => { + const f = await fixture(t); + for (const extra of [{ "../escape": strToU8("no") }, { "manifest.JSON": strToU8("no") }, { "bin/mcp.dll": strToU8("no") }, + { "results.jsonl": strToU8('{"secret":"do not log"') }, { "results.jsonl": strToU8(JSON.stringify({ ...trial, experiment: { runId: "x" } })) }, + { "results.jsonl": new Uint8Array() }, { "manifest.json": strToU8(JSON.stringify({ ...manifest, buildId: "0" })) }]) { + assert.throws(() => validateBundle(zip(extra)), { code: "invalid_bundle" }); + } + const bomb = Buffer.from(zip()), start = bomb.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + bomb.writeUInt32LE(128 * 1024 * 1024 + 1, start + 24); + assert.throws(() => validateBundle(bomb), { code: "invalid_bundle" }); + await assert.rejects(boundedFile(f.path, 1), { code: "invalid_bundle" }); + assert.throws(() => validateBundle(zip({ "results.jsonl": strToU8('{"secret":"do not log"') })), error => !error.message.includes("secret")); + assert.equal(validateBundle(zip({ "results.jsonl": strToU8(JSON.stringify(trial) + "\r\n") })).trials, 1); +}); + +test("canonical identity and UTC calendar validation match the reader", () => { + assert.equal(blobName({ ...manifest, adoProject: "Test Project" }), "v1/azure-sdk/test%20project/8255/1001/1/dashboard-bundle.zip"); + for (const key of ["adoOrganization", "adoProject", "repo", "pipeline", "runTimestamp"]) for (const padded of [` ${manifest[key]}`, `${manifest[key]} `]) { + assert.throws(() => validateManifest({ ...manifest, [key]: padded }), { code: "invalid_bundle" }); + } + for (const branch of [" ", " refs/heads/main", "refs/heads/main "]) assert.throws(() => validateManifest({ ...manifest, branch }), { code: "invalid_bundle" }); + assert.throws(() => blobName({ ...manifest, adoProject: "\u754c".repeat(200) }), { code: "invalid_bundle" }); + for (const timestamp of ["2024-02-29T23:59:59Z", "2026-09-24T12:34:56.1234567Z", "2026-01-01T00:00:00Z"]) assert.equal(isUtcTimestamp(timestamp), true); + for (const timestamp of ["2026-02-30T00:00:00Z", "2026-02-29T00:00:00Z", "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00", "not a date", null]) { + assert.equal(isUtcTimestamp(timestamp), false); + assert.throws(() => validateManifest({ ...manifest, runTimestamp: timestamp }), { code: "invalid_bundle" }); + } +}); + +test("the archive package pins Node 24/pnpm and one portable SHA512-locked dependency", async () => { + const packageJson = JSON.parse(await readFile(resolve(import.meta.dirname, "../package.json"), "utf8")); + const lock = await readFile(resolve(import.meta.dirname, "../pnpm-lock.yaml"), "utf8"); + assert.equal(packageJson.engines.node, ">=24.14.1"); assert.equal(packageJson.packageManager, "pnpm@12.6.0"); + assert.deepEqual(packageJson.dependencies, { fflate: "0.8.3" }); + assert.deepEqual([...lock.matchAll(/^ ([^\s]+@[^:]+):/gm)].map(match => match[1]), ["fflate@0.8.3", "fflate@0.8.3"]); + assert.match(lock, /specifier: 0\.8\.3\s+version: 0\.8\.3/); + assert.match(lock, /integrity: sha512-tbZNuJrLwGUp3zshBtdy4W\+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==/); + assert.doesNotMatch(lock, /https?:|tarball:|sha1-|packageManagerDependencies/); +}); + +test("shared publisher tests run without Tools-only files", async t => { + const f = await fixture(t), consumer = join(f.root, "consumer"), scripts = resolve(import.meta.dirname, "../.."); + const copiedScripts = join(consumer, "eng/common/scripts/eval"), publisher = join(copiedScripts, "publisher"); + await cp(scripts, copiedScripts, { recursive: true, filter: source => !source.split(sep).includes("node_modules") }); + await cp(resolve(scripts, "../../pipelines"), join(consumer, "eng/common/pipelines"), { recursive: true }); + // Copy only the already-restored archive dependency; no install, Git history or service is needed. + await cp(await realpath(resolve(import.meta.dirname, "../node_modules/fflate")), join(publisher, "node_modules/fflate"), { recursive: true }); + for (const path of [".git", "eng/common-tests/ci.yml", "eng/common-tests/eval/Publish-EvalResults.Tests.ps1"]) { + await assert.rejects(readFile(join(consumer, path)), { code: "ENOENT" }); + } + const command = JSON.parse(await readFile(join(publisher, "package.json"), "utf8")).scripts.test; + assert.equal(command, "node --experimental-strip-types --test test/*.test.ts"); + for (const mode of ["without a redirect template", "with a consumer-owned redirect template"]) await t.test(mode, async () => { + if (mode.startsWith("with a")) { + const redirect = join(consumer, "eng/pipelines/templates/stages/1es-redirect.yml"); + await mkdir(resolve(redirect, ".."), { recursive: true }); + await writeFile(redirect, "parameters:\n- name: UseOfficial\n type: boolean\n default: true\n"); + } + const environment = { ...process.env }; delete environment.NODE_TEST_CONTEXT; + const args = command.split(" ").slice(1); + // Exclude this copy-and-run regression itself, not any archive or pipeline checks. + args.splice(args.indexOf("--test") + 1, 0, "--test-skip-pattern=shared publisher tests run without Tools-only files"); + const child = spawnSync(process.execPath, args, { cwd: publisher, env: environment, encoding: "utf8", + timeout: 120_000, maxBuffer: 2 * 1024 * 1024 }); + assert.ifError(child.error); assert.equal(child.status, 0, child.stderr + child.stdout); + }); +}); + +test("pipeline separates token-bearing attempt verification from rendering and uploads before the score gate", async () => { + const root = resolve(import.meta.dirname, "../../../.."); + const summary = await readFile(join(root, "pipelines/templates/jobs/eval-summarize.yml"), "utf8"); + const verification = summary.slice(summary.indexOf("# Only attempt verification"), summary.indexOf('"$(Build.SourcesDirectory)/eng/common/scripts/eval/build-eval-summary.ts"')); + assert.match(verification, /--attempts-output/); assert.match(verification, /SYSTEM_ACCESSTOKEN: \$\(System.AccessToken\)/); + const rendering = summary.slice(summary.indexOf('"$(Build.SourcesDirectory)/eng/common/scripts/eval/build-eval-summary.ts"')); + assert.match(rendering, /--attempts-file/); assert.doesNotMatch(rendering, /SYSTEM_ACCESSTOKEN/); + assert.ok(summary.indexOf("../steps/eval-publish-results.yml") < summary.indexOf("task: PublishTestResults@2")); + const steps = await readFile(join(root, "pipelines/templates/steps/eval-publish-results.yml"), "utf8"); + assert.match(steps, /pnpm\/bin\/pnpm\.mjs.*--ignore-workspace install --pm-on-fail=ignore --frozen-lockfile --prod --ignore-scripts/); + assert.match(steps, /NPM_CONFIG_USERCONFIG: \$\(Build.SourcesDirectory\)\/\.npmrc/); + assert.doesNotMatch(steps, /npm ci/); + for (const job of ["eval-shard.yml", "eval-summarize.yml"]) { + assert.match(await readFile(join(root, "pipelines/templates/jobs", job), "utf8"), /version: 24\.x/); + } + assert.doesNotMatch(steps, /registryUrl:|azure-sdk-tools\/npm\/registry/); + assert.match(steps, /scriptType: pscore[\s\S]*scriptLocation: inlineScript[\s\S]*Publish-EvalResults\.ps1/); + assert.match(steps, /azureSubscription: eval-dashboard-sc/); + assert.match(steps, /EVAL_STORAGE_ACCOUNT_NAME: \$\(EvalStorageAccountName\)/); + assert.match(steps, /EVAL_DASHBOARD_REFRESH_URL: \$\(EvalDashboardRefreshUrl\)/); + assert.match(steps, /EVAL_DASHBOARD_RESOURCE: \$\(EvalDashboardResource\)/); + assert.match(steps, /-DashboardRefreshUrl \$env:EVAL_DASHBOARD_REFRESH_URL -DashboardResource \$env:EVAL_DASHBOARD_RESOURCE/); + assert.doesNotMatch(steps, /ResultPath|storage-publication-result/); + const publisher = await readFile(resolve(import.meta.dirname, "../Publish-EvalResults.ps1"), "utf8"); + assert.doesNotMatch(publisher, /Save-Receipt|ResultPath|persist_storage_result|receipt_failed|File\]::(?:Write|Open|Move)|Set-Content/); + assert.ok(publisher.indexOf('Write-Host "Result archive stored:') < publisher.indexOf("'--resource', $DashboardResource"), "Confirm upload before notification without a local status file"); + assert.doesNotMatch(steps, /258998df|azsdk-eval-bue6/); + assert.doesNotMatch(await readFile(resolve(import.meta.dirname, "../Publish-EvalResults.ps1"), "utf8"), /258998df|azsdk-eval-bue6/); + assert.doesNotMatch(await readFile(resolve(import.meta.dirname, "../Publish-EvalResults.ps1"), "utf8"), /TF_BUILD|SYSTEM_|BUILD_|System\.DefinitionId/); +}); + +test("one checkbox gates trusted-run egress; owner configuration additionally gates runtime upload", async () => { + const root = resolve(import.meta.dirname, "../../../.."); + const archetype = await readFile(join(root, "pipelines/templates/stages/archetype-eval.yml"), "utf8"); + const steps = await readFile(join(root, "pipelines/templates/steps/eval-publish-results.yml"), "utf8"); + const gate = source => source.match(/\$\{\{ if (and\(parameters\.publishDashboardResults, .+\)) \}\}:/)?.[1]; + const networkGate = gate(archetype), uploadGate = gate(steps); + assert.ok(networkGate); assert.equal(networkGate, uploadGate); + assert.doesNotMatch(networkGate, /System.DefinitionId|EvalStorageAccountName/); + const runtime = steps.match(/ condition: (and\(.+\))/)?.[1]; assert.ok(runtime); + const evaluate = (expression, variables, enabled = true, succeeds = true) => runInNewContext(expression.replace(/\bin\(/g, "oneOf("), { + variables, parameters: { publishDashboardResults: enabled }, succeeded: () => succeeds, + and: (...values) => values.every(Boolean), eq: (a, b) => String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase(), + not: value => !value, + startsWith: (value, prefix) => String(value ?? "").toLowerCase().startsWith(String(prefix).toLowerCase()), + endsWith: (value, suffix) => String(value ?? "").toLowerCase().endsWith(String(suffix).toLowerCase()), + ne: (a, b) => String(a ?? "").toLowerCase() !== String(b ?? "").toLowerCase(), + oneOf: (value, ...items) => items.some(item => String(value ?? "").toLowerCase() === String(item).toLowerCase()), + }); + const base = { "System.CollectionUri": "https://dev.azure.com/azure-sdk/", "System.TeamProject": "internal", "Build.Repository.Name": "Azure/azure-sdk-tools", + "Build.SourceBranch": "refs/heads/main", "Build.Reason": "Manual", "EvalSummaryComplete": "true", "EvalStorageAccountName": "evaltestsummary" }; + for (const repository of ["Azure/azure-sdk-tools", "Azure/azure-rest-api-specs", "Azure/azure-sdk-for-python", "Azure/new-sdk-repository"]) { + const trusted = { ...base, "Build.Repository.Name": repository }; + for (const reason of ["Manual", "Schedule", "IndividualCI", "BatchedCI"]) { + const variables = { ...trusted, "Build.Reason": reason }; + assert.equal(evaluate(networkGate, variables), true, `${repository}: ${reason} egress`); + assert.equal(evaluate(runtime, variables), true, `${repository}: ${reason} upload`); + } + assert.equal(evaluate(networkGate, trusted, false), false); + assert.equal(evaluate(runtime, trusted, true, false), false); + for (const account of [undefined, ""]) assert.equal(evaluate(runtime, { ...trusted, EvalStorageAccountName: account }), false); + for (const complete of [undefined, "", "false"]) assert.equal(evaluate(runtime, { ...trusted, EvalSummaryComplete: complete }), false); + for (const [key, value] of [["System.CollectionUri", "https://dev.azure.com/other/"], ["System.TeamProject", "public"], + ["Build.Repository.Name", "example-org/azure-rest-api-specs"], ["Build.Repository.Name", "Azure/azure-rest-api-specs-pr"], + ["Build.Repository.Name", "Azure/azure-sdk-for-python-pr"], ["Build.Repository.Name", "example-org/new-sdk-repository"], + ["Build.Reason", "PullRequest"], ["Build.Reason", "ResourceTrigger"], + ...["refs/pull/1/merge", "refs/heads/feature", "refs/heads/feature/main", "refs/tags/main", ""].map(branch => ["Build.SourceBranch", branch])]) { + const variables = { ...trusted, [key]: value }; + assert.equal(evaluate(networkGate, variables), false); assert.equal(evaluate(runtime, variables), false); + } + } + for (const id of ["8255", "9999"]) assert.equal(evaluate(networkGate, { ...base, "System.DefinitionId": id }), true, "Definition IDs are archive identity, not shared policy"); +}); + +test("shared publication defaults stay off", async () => { + const root = resolve(import.meta.dirname, "../../../.."); + for (const path of ["templates/steps/eval-publish-results.yml", "templates/jobs/eval-summarize.yml", "templates/stages/archetype-eval.yml"]) { + assert.match(await readFile(join(root, "pipelines", path), "utf8"), /name: publishDashboardResults\s+type: boolean\s+default: false/); + } +}); + +for (const tier of ["workflow", "skill", "live"]) test(`${tier} forwards only the checkbox and preserves its evaluation tier`, async () => { + const root = resolve(import.meta.dirname, "../../../.."), content = await readFile(join(root, `pipelines/${tier}-eval.yml`), "utf8"); + assert.match(content, /createDashboardBundle: true/); assert.match(content, /name: publishDashboardResults[\s\S]*default: true/); + assert.ok(content.includes("publishDashboardResults: ${{ parameters.publishDashboardResults }}")); + assert.doesNotMatch(content, /eval-dashboard\.yml|EvalDashboardPublicationEnabled|System.DefinitionId|autoPublishDashboardResults|allowAzureStorageNetworkAccess/); + const inputs = content.slice(content.indexOf("\nparameters:\n"), content.indexOf("\nvariables:\n")); + assert.deepEqual([...inputs.matchAll(/- name: (\w+)/g)].map(match => match[1]), ["publishDashboardResults"]); + assert.doesNotMatch(content, /group:\s*AzSDK_Eval_Variable_group/); + assert.match(content, /template: \/eng\/common\/pipelines\/templates\/stages\/archetype-eval\.yml/); + const shard = await readFile(join(root, "pipelines/templates/jobs/eval-shard.yml"), "utf8"); + assert.match(shard, /template: \/eng\/common\/pipelines\/templates\/steps\/login-to-copilot\.yml/); + const login = await readFile(join(root, "pipelines/templates/steps/login-to-copilot.yml"), "utf8"); + assert.match(login, /template: \/eng\/common\/pipelines\/templates\/steps\/login-to-github\.yml/); + assert.match(login, /VariableNamePrefix:\s*COPILOT_GITHUB_TOKEN/); + assert.ok(login.includes("- ${{ split(variables['Build.Repository.Name'], '/')[1] }}")); + assert.match(login, /organization_copilot_requests:\s*read/); + assert.match(login, /metadata:\s*read/); + assert.match(content, new RegExp(`TestType: ${tier === "live" ? "live" : "mock"}`)); + if (tier === "live") { assert.match(content, /UseAzSdkAuthentication: true/); assert.match(content, /failOnFailedTests: true/); } + if (tier === "skill") assert.match(content, /vallyRoot: \.github\/skills/); +}); \ No newline at end of file diff --git a/eng/common/scripts/eval/test/shard-results.test.ts b/eng/common/scripts/eval/test/shard-results.test.ts new file mode 100644 index 000000000000..68fb908b085b --- /dev/null +++ b/eng/common/scripts/eval/test/shard-results.test.ts @@ -0,0 +1,571 @@ +// Verifies complete result staging, authoritative attempts and recovery after transport failures. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { expectedShardsFromMatrix, latestShardAttempts, readShardAttempts, selectSummaryResults, stageShardResults } from "../lib/shard-results.ts"; +import { getVallyShardVerdict } from "../lib/verdict.ts"; + +function setup(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "eval-artifacts-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function invocation(root, name, { failed = false, summary = true, malformed = false, junit = true } = {}) { + const directory = path.join(root, name); + fs.mkdirSync(directory, { recursive: true }); + const records = [{ type: "trial-result", status: failed ? "error" : "success", itemId: name }]; + if (summary) records.push({ type: "run-summary", evals: [{ name, stimuliRun: 1, passed: !failed }] }); + fs.writeFileSync(path.join(directory, "results.jsonl"), records.map((entry) => JSON.stringify(entry)).join("\n") + (malformed ? '\n{"type":' : "\n")); + if (junit) fs.writeFileSync(path.join(directory, "eval-results.junit.xml"), + `${failed ? '' : ""}`); + return directory; +} + +function artifact(root, shard, attempt, options = {}) { + const source = invocation(root, `source-${shard}-${attempt}`, options); + const output = path.join(root, "download", `eval-result-${shard}-${attempt}`); + stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: shard, attempt }); + const evidence = path.join(root, "job-attempts.json"); + const timeline = fs.existsSync(evidence) ? readJson(evidence) : { schemaVersion: 1, valid: true, attempts: {} }; + if (!timeline.attempts[shard] || timeline.attempts[shard].attempt < attempt) timeline.attempts[shard] = { attempt, complete: true }; + fs.writeFileSync(evidence, JSON.stringify(timeline)); + return output; +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function runCli(scriptName, args, environment = {}, cwd = undefined) { + const script = fileURLToPath(new URL(`../${scriptName}`, import.meta.url)); + const env = { ...process.env, ...environment }; + delete env.NODE_TEST_CONTEXT; + for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]; + const preload = []; + if (scriptName === "build-eval-summary.ts" && args.includes("--selected-root")) { + const root = path.dirname(args[args.indexOf("--results-root") + 1]); + fs.mkdirSync(root, { recursive: true }); + const mock = path.join(root, "rendering-offline.mjs"); + fs.writeFileSync(mock, `globalThis.fetch = () => { throw new Error("Summary rendering cannot contact a service"); };`); + delete env.SYSTEM_ACCESSTOKEN; + preload.push("--import", pathToFileURL(mock).href); + args = [...args, "--attempts-file", path.join(root, "job-attempts.json")]; + } + const child = spawnSync(process.execPath, ["--experimental-strip-types", ...preload, script, ...args], { + encoding: "utf8", env, cwd, timeout: 30_000, maxBuffer: 1024 * 1024, + }); + assert.ifError(child.error); + return child; +} + +function runSummary(root, shards, options = {}) { + const output = path.join(root, "summary", "eval-summary.md"); + const matrix = Object.hasOwn(options, "matrix") ? options.matrix : + JSON.stringify(Object.fromEntries(shards.map((shardName) => [shardName, { shardName }]))); + const child = runCli("build-eval-summary.ts", [ + "--results-root", path.join(root, "download"), "--selected-root", path.join(root, "selected"), + "--output-path", output, + ], { TF_BUILD: "true", EVAL_EXPECTED_MATRIX: matrix }); + return { + child, markdown: fs.readFileSync(output, "utf8"), + result: readJson(path.join(root, "summary", "eval-summary.json")), + index: readJson(path.join(root, "download", "shard-index.json")), + }; +} + +test("expected shards come from the full Prepare matrix, not arrived artifacts or matrix keys", () => { + const matrix = { secondJob: { shardName: "area_b", evalArgs: "-e b.eval.yaml" }, firstJob: { shardName: "area_a" } }; + assert.deepEqual(expectedShardsFromMatrix(matrix), ["area_a", "area_b"]); + assert.deepEqual(expectedShardsFromMatrix(JSON.stringify(matrix)), ["area_a", "area_b"]); + for (const invalid of [undefined, "not JSON", {}, [], null, true, 1, { a: null }, { a: { shardName: "../escape" } }, + { a: { shardName: "" } }, { a: { shardName: "x".repeat(201) } }, { a: { shardName: "same" }, b: { shardName: "same" } }]) { + assert.throws(() => expectedShardsFromMatrix(invalid)); + } +}); + +test("completed failing evaluations stage exact raw bytes and all JUnit, but no unrelated files", (t) => { + const root = setup(t); + const source = invocation(root, "source", { failed: true }); + fs.writeFileSync(path.join(source, "debug.log"), "not bundled"); + fs.writeFileSync(path.join(source, "mcp.dll"), "not bundled"); + fs.mkdirSync(path.join(source, "nested")); + fs.copyFileSync(path.join(source, "eval-results.junit.xml"), path.join(source, "nested", "eval-results.junit.xml")); + const output = path.join(root, "staged"); + const result = stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: "area_test", attempt: 2 }); + assert.deepEqual(result, { schemaVersion: 1, shard: "area_test", attempt: 2, complete: true, trials: 1, reason: null }); + assert.deepEqual(readJson(path.join(output, "shard.json")), result); + assert.deepEqual(fs.readdirSync(output).sort(), ["junit", "results.jsonl", "shard.json"]); + assert.deepEqual(fs.readdirSync(path.join(output, "junit")), ["0.junit.xml", "1.junit.xml"]); + assert.deepEqual(fs.readFileSync(path.join(output, "results.jsonl")), fs.readFileSync(path.join(source, "results.jsonl"))); +}); + +test("missing, interrupted and corrupt invocations publish incomplete markers", (t) => { + const root = setup(t); + for (const [index, options] of [{ summary: false }, { malformed: true }, { junit: false }].entries()) { + const source = invocation(root, `source-${index}`, options); + const output = path.join(root, `stage-${index}`); + const result = stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: "test", attempt: 1 }); + assert.equal(result.complete, false); + assert.equal(result.trials, 1); + assert.ok(result.reason); + assert.deepEqual(readJson(path.join(output, "shard.json")), result); + assert.deepEqual(fs.readFileSync(path.join(output, "results.jsonl")), fs.readFileSync(path.join(source, "results.jsonl"))); + } + const output = path.join(root, "empty"); + const empty = stageShardResults({ resultsRoot: path.join(root, "not-created"), outputDirectory: output, shardName: "test", attempt: 1 }); + assert.equal(empty.complete, false); + assert.equal(empty.trials, 0); + assert.deepEqual(fs.readdirSync(output).sort(), ["junit", "shard.json"]); +}); + +test("non-object JSONL and empty final summaries are incomplete, and a trial invalidates an earlier summary", (t) => { + const root = setup(t); + const suffixes = ["null", "[]", "42", '{"type":"run-summary","evals":[]}', + '{"type":"run-summary","evals":null}', '{"type":"trial-result","status":"success"}']; + for (const [index, suffix] of suffixes.entries()) { + const source = invocation(root, `source-${index}`); + fs.appendFileSync(path.join(source, "results.jsonl"), suffix + "\n"); + const result = stageShardResults({ resultsRoot: source, outputDirectory: path.join(root, `staged-${index}`), shardName: "test", attempt: 1 }); + assert.equal(result.complete, false); + } +}); + +test("untyped trial records, CRLF and blank lines retain the reference completion semantics", (t) => { + const root = setup(t); + const source = invocation(root, "source"); + fs.writeFileSync(path.join(source, "results.jsonl"), '\r\n{"status":"success"}\r\n \r\n{"type":"run-summary","evals":[{"name":"legacy"}]}\r\n'); + const result = stageShardResults({ resultsRoot: source, outputDirectory: path.join(root, "staged"), shardName: "test", attempt: 1 }); + assert.equal(result.complete, true); + assert.equal(result.trials, 1); +}); + +test("staging selects the same newest invocation as the shard verdict", (t) => { + const root = setup(t); + const old = invocation(root, "runs/old"); + const latest = invocation(root, "runs/new", { failed: true }); + fs.utimesSync(path.join(old, "results.jsonl"), 1, 1); + fs.utimesSync(path.join(latest, "results.jsonl"), 2, 2); + const output = path.join(root, "stage"); + assert.equal(getVallyShardVerdict({ resultsDir: path.join(root, "runs") }).passed, false); + stageShardResults({ resultsRoot: path.join(root, "runs"), outputDirectory: output, shardName: "test", attempt: 1 }); + assert.deepEqual(fs.readFileSync(path.join(output, "results.jsonl")), fs.readFileSync(path.join(latest, "results.jsonl"))); + assert.deepEqual(fs.readFileSync(path.join(output, "junit", "0.junit.xml")), fs.readFileSync(path.join(latest, "eval-results.junit.xml"))); +}); + +test("equal-mtime invocation ties use the verdict's path order, not localeCompare", (t) => { + const root = setup(t); + const first = invocation(root, "runs/Z"); + const last = invocation(root, "runs/a", { failed: true }); + for (const directory of [first, last]) fs.utimesSync(path.join(directory, "results.jsonl"), 1, 1); + const output = path.join(root, "stage"); + assert.equal(getVallyShardVerdict({ resultsDir: path.join(root, "runs") }).passed, false); + stageShardResults({ resultsRoot: path.join(root, "runs"), outputDirectory: output, shardName: "test", attempt: 1 }); + assert.deepEqual(fs.readFileSync(path.join(output, "results.jsonl")), fs.readFileSync(path.join(last, "results.jsonl"))); +}); + +test("a corrupt newest invocation never falls back to older complete output", (t) => { + const root = setup(t); + const old = invocation(root, "runs/old"); + const latest = invocation(root, "runs/new", { malformed: true }); + fs.utimesSync(path.join(old, "results.jsonl"), 1, 1); + fs.utimesSync(path.join(latest, "results.jsonl"), 2, 2); + const output = path.join(root, "stage"); + const result = stageShardResults({ resultsRoot: path.join(root, "runs"), outputDirectory: output, shardName: "test", attempt: 1 }); + assert.equal(result.complete, false); + assert.deepEqual(fs.readFileSync(path.join(output, "results.jsonl")), fs.readFileSync(path.join(latest, "results.jsonl"))); +}); + +test("staging requires an empty destination and does not delete existing contents", (t) => { + const root = setup(t); + const source = invocation(root, "source"); + const output = path.join(root, "staged"); + fs.mkdirSync(output); + fs.writeFileSync(path.join(output, "keep.txt"), "keep me"); + assert.throws(() => stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: "test", attempt: 1 }), /must be empty/); + assert.deepEqual(fs.readdirSync(output), ["keep.txt"]); + assert.equal(fs.readFileSync(path.join(output, "keep.txt"), "utf8"), "keep me"); +}); + +test("invalid shard identities or attempts are rejected before creating an output", (t) => { + const root = setup(t); + const output = path.join(root, "staged"); + for (const shardName of ["", "../escape", "a/b", "a\\b", "a\n", "x".repeat(201), undefined]) { + assert.throws(() => stageShardResults({ resultsRoot: root, outputDirectory: output, shardName, attempt: 1 })); + } + for (const attempt of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => stageShardResults({ resultsRoot: root, outputDirectory: output, shardName: "test", attempt })); + } + assert.equal(fs.existsSync(output), false); +}); + +test("symbolic-link destinations are not treated as owned empty directories", (t) => { + const root = setup(t); + const source = invocation(root, "source"); + const target = path.join(root, "target"); + const output = path.join(root, "link"); + fs.mkdirSync(target); + fs.symlinkSync(target, output, "junction"); + assert.throws(() => stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: "test", attempt: 1 }), /symbolic link/); + assert.deepEqual(fs.readdirSync(target), []); +}); + +test("oversized raw files are not read or copied, but JUnit and an incomplete marker survive", (t) => { + const root = setup(t); + const source = invocation(root, "source"); + const fd = fs.openSync(path.join(source, "results.jsonl"), "r+"); + try { fs.ftruncateSync(fd, 128 * 1024 * 1024 + 1); } finally { fs.closeSync(fd); } + const output = path.join(root, "stage"); + const result = stageShardResults({ resultsRoot: source, outputDirectory: output, shardName: "test", attempt: 1 }); + assert.equal(result.complete, false); + assert.match(result.reason, /oversized/); + assert.equal(fs.existsSync(path.join(output, "results.jsonl")), false); + assert.deepEqual(fs.readdirSync(path.join(output, "junit")), ["0.junit.xml"]); + assert.equal(readJson(path.join(output, "shard.json")).complete, false); +}); + +test("summary selects latest expected attempts once; completed failures remain eligible for publication", (t) => { + const root = setup(t); + artifact(root, "area_a", 1, { failed: true }); + artifact(root, "area_a", 2); + artifact(root, "area_b", 1, { failed: true }); + artifact(root, "unrequested", 9); + const { child, markdown, result, index } = runSummary(root, ["area_b", "area_a"]); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /##vso\[task.setvariable variable=EvalSummaryComplete\]true/); + assert.deepEqual(result, { + schemaVersion: 1, complete: true, + shards: [{ shard: "area_a", attempt: 2, complete: true, reason: null }, { shard: "area_b", attempt: 1, complete: true, reason: null }], + totals: { scenarios: 2, failed: 1, skipped: 0 }, + }); + assert.match(markdown, /results — FAILED/); + assert.match(markdown, /\| area_a \| 2 \| Complete \|/); + assert.deepEqual(fs.readdirSync(path.join(root, "selected")).sort(), ["eval-result-area_a-2", "eval-result-area_b-1"]); + assert.deepEqual(index, { + schemaVersion: 1, complete: true, expectedShards: ["area_a", "area_b"], + attempts: [{ shard: "area_a", attempt: 2, directory: "eval-result-area_a-2" }, { shard: "area_b", attempt: 1, directory: "eval-result-area_b-1" }], + }); +}); + +test("a later incomplete attempt and a missing shard never fall back to complete older artifacts", (t) => { + const root = setup(t); + artifact(root, "area_a", 1); + artifact(root, "area_a", 2, { junit: false }); + const { child, markdown, result, index } = runSummary(root, ["area_a", "area_missing"]); + assert.equal(child.status, 1); + assert.match(child.stdout, /##vso\[task.setvariable variable=EvalSummaryComplete\]false/); + assert.equal(result.complete, false); + assert.equal(index.complete, false); + assert.match(markdown, /results — INCOMPLETE/); + assert.doesNotMatch(markdown, /results — PASSED/); + assert.equal(result.shards[0].attempt, 2); + assert.equal(result.shards[1].attempt, null); + assert.equal(result.totals.scenarios, 0); + assert.deepEqual(index.expectedShards, ["area_a", "area_missing"]); + assert.deepEqual(index.attempts, [{ shard: "area_a", attempt: 2, directory: "eval-result-area_a-2" }]); +}); + +test("attempt selection is numeric and includes the newest directory even when it is empty", (t) => { + const root = setup(t); + artifact(root, "area_a", 9); + fs.mkdirSync(path.join(root, "download", "eval-result-area_a-10")); + fs.writeFileSync(path.join(root, "job-attempts.json"), JSON.stringify({ schemaVersion: 1, valid: true, + attempts: { area_a: { attempt: 10, complete: true } } })); + const { child, result, index } = runSummary(root, ["area_a"]); + assert.equal(child.status, 1); + assert.equal(result.shards[0].attempt, 10); + assert.equal(result.totals.scenarios, 0); + assert.equal(index.attempts[0].attempt, 10); + assert.deepEqual(fs.readdirSync(path.join(root, "selected")), ["eval-result-area_a-10"]); +}); + +test("zero parsed JUnit cases make that shard incomplete even when another shard has results", (t) => { + const root = setup(t); + const output = artifact(root, "area_a", 1); + fs.writeFileSync(path.join(output, "junit", "0.junit.xml"), ""); + artifact(root, "area_b", 1); + const { child, result, index, markdown } = runSummary(root, ["area_a", "area_b"]); + assert.equal(child.status, 1); + assert.equal(result.complete, false); + assert.equal(index.complete, false); + assert.equal(result.totals.scenarios, 1); + assert.match(result.shards[0].reason, /no evaluation testcases/); + assert.equal(result.shards[1].complete, true); + assert.match(markdown, /results — INCOMPLETE/); +}); + +test("bad completion metadata cannot hide available diagnostic JUnit", (t) => { + const root = setup(t); + const base = { schemaVersion: 1, shard: "area_a", attempt: 1, complete: true, trials: 1 }; + const markers = ["not JSON", "null", JSON.stringify({}), JSON.stringify({ ...base, schemaVersion: 2 }), + JSON.stringify({ ...base, shard: "other" }), JSON.stringify({ ...base, attempt: 2 }), + JSON.stringify({ ...base, complete: "true" }), JSON.stringify({ ...base, trials: 0 }), + JSON.stringify({ ...base, trials: 1.5 }), JSON.stringify({ ...base, trials: 2 }), + JSON.stringify({ ...base, padding: " ".repeat(64 * 1024) })]; + for (const [index, marker] of markers.entries()) { + const directory = path.join(root, `case-${index}`); + const output = artifact(directory, "area_a", 1); + fs.writeFileSync(path.join(output, "shard.json"), marker); + const selection = selectSummaryResults({ resultsRoot: path.join(directory, "download"), selectedRoot: path.join(directory, "selected"), + expectedShards: ["area_a"], jobAttempts: readJson(path.join(directory, "job-attempts.json")) }); + assert.equal(selection.complete, false); + assert.deepEqual(fs.readdirSync(path.join(directory, "selected", "eval-result-area_a-1", "junit")), ["0.junit.xml"]); + } +}); + +test("missing or corrupted raw results override a complete marker without losing diagnostic cases", (t) => { + const root = setup(t); + for (const corrupt of [false, true]) { + const directory = path.join(root, String(corrupt)); + artifact(directory, "area_a", 1); + const output = artifact(directory, "area_a", 2); + if (corrupt) fs.appendFileSync(path.join(output, "results.jsonl"), "not JSON\n"); + else fs.unlinkSync(path.join(output, "results.jsonl")); + const { child, result, index } = runSummary(directory, ["area_a"]); + assert.equal(child.status, 1); + assert.equal(result.complete, false); + assert.equal(result.shards[0].attempt, 2); + assert.equal(result.totals.scenarios, 1); + assert.equal(index.complete, false); + } +}); + +test("legacy-layout JUnit is diagnostic only, and never causes fallback to an older attempt", (t) => { + const root = setup(t); + artifact(root, "area_a", 1); + const latest = path.join(root, "download", "eval-result-area_a-2"); + fs.mkdirSync(latest); + fs.writeFileSync(path.join(latest, "eval-results.junit.xml"), ''); + fs.writeFileSync(path.join(root, "job-attempts.json"), JSON.stringify({ schemaVersion: 1, valid: true, + attempts: { area_a: { attempt: 2, complete: true } } })); + const { child, result, index, markdown } = runSummary(root, ["area_a"]); + assert.equal(child.status, 1); + assert.equal(result.complete, false); + assert.deepEqual(result.totals, { scenarios: 1, failed: 1, skipped: 0 }); + assert.equal(index.attempts[0].attempt, 2); + assert.match(markdown, /latest \(0\/1 runs passed\)/); + assert.equal(fs.existsSync(path.join(root, "selected", "eval-result-area_a-1")), false); +}); + +test("failed Prepare retains incomplete summary contracts without inferring expected shards", (t) => { + const root = setup(t); + for (const [index, matrix] of [undefined, "", "not JSON", "{}", "[]"].entries()) { + const directory = path.join(root, `case-${index}`); + artifact(directory, "arrived", 1); + const { child, markdown, result, index: shardIndex } = runSummary(directory, [], { matrix }); + assert.equal(child.status, 1); + assert.equal(result.complete, false); + assert.equal(result.totals.scenarios, 0); + assert.match(markdown, /results — INCOMPLETE/); + assert.match(markdown, /check the Prepare stage/); + assert.deepEqual(shardIndex, { schemaVersion: 1, complete: false, expectedShards: [], attempts: [] }); + assert.deepEqual(fs.readdirSync(path.join(directory, "selected")), []); + } +}); + +test("a missing artifact root reports every expected shard missing", (t) => { + const root = setup(t); + const { child, result, index } = runSummary(root, ["area_a", "area_b"]); + assert.equal(child.status, 1); + assert.equal(result.complete, false); + assert.equal(result.totals.scenarios, 0); + assert.ok(result.shards.every((entry) => entry.attempt === null)); + assert.deepEqual(index.expectedShards, ["area_a", "area_b"]); + assert.deepEqual(index.attempts, []); +}); + +test("summary cannot publish when timeline evidence is unavailable, invalid or corrupt", (t) => { + const root = setup(t); + for (const mode of ["invalid", "missing", "corrupt"]) { + const directory = path.join(root, mode); + artifact(directory, "area_a", 1); + const attempts = path.join(directory, "job-attempts.json"); + if (mode === "invalid") fs.writeFileSync(attempts, JSON.stringify({ valid: false })); + if (mode === "missing") fs.unlinkSync(attempts); + if (mode === "corrupt") fs.writeFileSync(attempts, "not JSON"); + const output = path.join(directory, "summary", "eval-summary.md"); + const child = runCli("build-eval-summary.ts", ["--results-root", path.join(directory, "download"), + "--selected-root", path.join(directory, "selected"), "--output-path", output], { + TF_BUILD: "true", EVAL_EXPECTED_MATRIX: JSON.stringify({ a: { shardName: "area_a" } }), + }); + assert.equal(child.status, 1); assert.match(child.stdout, /EvalSummaryComplete\]false/); + assert.equal(readJson(path.join(directory, "download", "shard-index.json")).complete, false); + assert.equal(readJson(path.join(directory, "summary", "eval-summary.json")).totals.scenarios, 0); + assert.deepEqual(fs.readdirSync(path.join(directory, "selected")), []); + assert.deepEqual(readJson(path.join(directory, "download", "shard-index.json")).attempts, []); + assert.match(fs.readFileSync(output, "utf8"), /timeline/); + assert.equal(fs.existsSync(path.join(directory, "summary", "job-attempts.json")), false, "Rendering never fabricates timeline evidence"); + } +}); + +test("selection rejects stale destinations and never treats zero expected shards as complete", (t) => { + const root = setup(t); + artifact(root, "area_a", 1); + const selectedRoot = path.join(root, "selected"); + fs.mkdirSync(selectedRoot); + fs.writeFileSync(path.join(selectedRoot, "keep.txt"), "keep me"); + assert.throws(() => selectSummaryResults({ resultsRoot: path.join(root, "download"), selectedRoot, expectedShards: ["area_a"] }), /must be empty/); + assert.equal(fs.readFileSync(path.join(selectedRoot, "keep.txt"), "utf8"), "keep me"); + assert.equal(fs.existsSync(path.join(root, "download", "shard-index.json")), false); + const selection = selectSummaryResults({ resultsRoot: path.join(root, "download"), selectedRoot: path.join(root, "empty-selection"), expectedShards: [] }); + assert.equal(selection.complete, false); + assert.equal(selection.shardInput.complete, false); + assert.deepEqual(selection.shardInput.attempts, []); +}); + +test("ambiguous numeric attempt names and invalid attempts are rejected", (t) => { + const root = setup(t); + for (const [index, names] of [["01", "1", "2"], ["0"], ["9007199254740992"]].entries()) { + const directory = path.join(root, `case-${index}`); + const resultsRoot = path.join(directory, "download"); + for (const name of names) fs.mkdirSync(path.join(resultsRoot, `eval-result-area_a-${name}`), { recursive: true }); + assert.throws(() => selectSummaryResults({ resultsRoot, selectedRoot: path.join(directory, "selected"), expectedShards: ["area_a"] }), /artifact attempt/i); + } +}); + +test("staging CLI logs counts and sanitized warnings, never the raw invalid record", (t) => { + const root = setup(t); + const source = invocation(root, "source"); + const privateText = "SYNTHETIC_RAW_RECORD_DO_NOT_LOG"; + fs.appendFileSync(path.join(source, "results.jsonl"), privateText); + const output = path.join(root, "stage"); + const child = runCli("lib/shard-results.ts", ["--results-root", source, "--output-directory", output, "--shard-name", "area_test", "--attempt", "3"]); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /Staged area_test attempt 3: 1 trials; incomplete/); + assert.match(child.stdout, /##vso\[task.logissue type=warning\]/); + assert.ok(!(child.stdout + child.stderr).includes(privateText)); + assert.equal(readJson(path.join(output, "shard.json")).complete, false); +}); + +test("summary CLI without selected-root preserves legacy JUnit-only behavior and default output", (t) => { + const root = setup(t); + const results = path.join(root, "download", "eval-result-legacy"); + fs.mkdirSync(results, { recursive: true }); + fs.writeFileSync(path.join(results, "junit.xml"), ''); + const child = runCli("build-eval-summary.ts", ["--results-root", path.join(root, "download")], + { TF_BUILD: "true", EVAL_EXPECTED_MATRIX: "invalid matrix is irrelevant in legacy mode" }, root); + assert.equal(child.status, 0, child.stderr); + assert.match(fs.readFileSync(path.join(root, "eval-summary.md"), "utf8"), /results — PASSED/); + assert.match(child.stdout, /##vso\[task.uploadsummary\]/); + assert.doesNotMatch(child.stdout, /EvalSummaryComplete/); + assert.equal(fs.existsSync(path.join(root, "eval-summary.json")), false); + assert.equal(fs.existsSync(path.join(root, "download", "shard-index.json")), false); +}); + +test("a selected-root flag without a value cannot silently fall back to legacy mode", (t) => { + const root = setup(t); + const child = runCli("build-eval-summary.ts", ["--results-root", root, "--selected-root"], { TF_BUILD: "true" }, root); + assert.equal(child.status, 1); + assert.match(child.stderr, /Missing value for --selected-root/); + assert.match(child.stdout, /##vso\[task.setvariable variable=EvalSummaryComplete\]false/); + assert.equal(fs.existsSync(path.join(root, "eval-summary.md")), false); +}); + +test("invalid Prepare cannot reuse a stale selected directory", (t) => { + const root = setup(t); + const selected = path.join(root, "selected"); + fs.mkdirSync(selected); + fs.writeFileSync(path.join(selected, "keep.txt"), "keep me"); + const child = runCli("build-eval-summary.ts", ["--results-root", path.join(root, "download"), "--selected-root", selected], + { TF_BUILD: "true", EVAL_EXPECTED_MATRIX: "{}" }, root); + assert.equal(child.status, 1); + assert.match(child.stderr, /must be empty/); + assert.match(child.stdout, /##vso\[task.setvariable variable=EvalSummaryComplete\]false/); + assert.deepEqual(fs.readdirSync(selected), ["keep.txt"]); + assert.equal(fs.readFileSync(path.join(selected, "keep.txt"), "utf8"), "keep me"); +}); + +const timelineEnv = { SYSTEM_COLLECTIONURI: "https://dev.azure.com/azure-sdk/", SYSTEM_TEAMPROJECTID: "00000000-0000-4000-8000-000000000001", + BUILD_BUILDID: "1001", SYSTEM_ACCESSTOKEN: "fixture-token", EVAL_EXPECTED_MATRIX: '{"a":{"shardName":"a"}}' }; +const timelineRecord = { type: "Job", identifier: "Eval.RunShard.a", attempt: 1, state: "completed", result: "failed" }; + +test("timeline uses matrix identifiers, highest attempts and completed state, rejecting ambiguous attempts", () => { + const matrix = { a: { shardName: "a" } }, records = [timelineRecord, { ...timelineRecord, attempt: 2 }]; + assert.deepEqual(latestShardAttempts({ records }, matrix).attempts.a, { attempt: 2, complete: true }); + records[1].result = "canceled"; + assert.equal(latestShardAttempts({ records }, matrix).attempts.a.complete, false); + assert.throws(() => latestShardAttempts({ records: [] }, matrix)); + assert.throws(() => latestShardAttempts({ records }, {})); + assert.throws(() => latestShardAttempts({ records: [timelineRecord, { ...timelineRecord, result: "canceled" }] }, matrix), /Ambiguous/); +}); + +test("newer failed attempt with no artifact cannot adopt an older complete artifact", t => { + const root = setup(t); artifact(root, "a", 1); + for (const [index, jobAttempts] of [undefined, null, + { schemaVersion: 1, valid: true, attempts: { a: { attempt: 2, complete: true } } }, + { schemaVersion: 1, valid: true, attempts: { a: { attempt: 1, complete: false } } }, + { schemaVersion: 1, valid: false, attempts: {} }].entries()) { + const selected = selectSummaryResults({ resultsRoot: path.join(root, "download"), selectedRoot: path.join(root, `selection-${index}`), expectedShards: ["a"], jobAttempts }); + assert.equal(selected.complete, false); assert.match(selected.status[0].reason, /timeline/); + const matchingAttempt = jobAttempts?.valid === true && jobAttempts.attempts.a?.attempt === 1; + assert.equal(selected.shardInput.attempts.length, matchingAttempt ? 1 : 0); + assert.equal(fs.existsSync(path.join(root, `selection-${index}`, "eval-result-a-1", "junit", "0.junit.xml")), matchingAttempt); + } +}); + +test("missing latest results contribute no older JUnit to Markdown, counts or selected test files", t => { + const root = setup(t); artifact(root, "a", 1); + const evidence = { schemaVersion: 1, valid: true, attempts: { a: { attempt: 2, complete: true } } }; + fs.writeFileSync(path.join(root, "job-attempts.json"), JSON.stringify(evidence)); + const { child, markdown, result, index } = runSummary(root, ["a"]); + assert.equal(child.status, 1); + assert.match(child.stdout, /EvalSummaryComplete\]false/); + assert.equal(result.complete, false); assert.equal(index.complete, false); + assert.equal(result.shards[0].attempt, 2); assert.match(result.shards[0].reason, /no matching artifact/); + assert.deepEqual(result.totals, { scenarios: 0, failed: 0, skipped: 0 }); + assert.deepEqual(index.attempts, []); + assert.deepEqual(fs.readdirSync(path.join(root, "selected")), []); + assert.doesNotMatch(markdown, /source-a-1|results — PASSED/); + assert.match(markdown, /results — INCOMPLETE/); +}); + +test("only the timeline-matching attempt is selected, even when newer unverified artifacts exist", t => { + const root = setup(t); artifact(root, "a", 1); artifact(root, "a", 2, { failed: true }); artifact(root, "a", 3); + fs.writeFileSync(path.join(root, "job-attempts.json"), JSON.stringify({ schemaVersion: 1, valid: true, + attempts: { a: { attempt: 2, complete: true } } })); + const { child, markdown, result, index } = runSummary(root, ["a"]); + assert.equal(child.status, 0, child.stderr); + assert.equal(result.complete, true); assert.equal(index.complete, true); + assert.equal(result.shards[0].attempt, 2); + assert.deepEqual(result.totals, { scenarios: 1, failed: 1, skipped: 0 }); + assert.deepEqual(fs.readdirSync(path.join(root, "selected")), ["eval-result-a-2"]); + assert.deepEqual(index.attempts, [{ shard: "a", attempt: 2, directory: "eval-result-a-2" }]); + assert.match(markdown, /results — FAILED/); + assert.doesNotMatch(markdown, /source-a-1|source-a-3/); +}); + +test("timeline reads retry transient failures only, with a fixed three-attempt budget", async () => { + const waits = []; let calls = 0; + const result = await readShardAttempts(timelineEnv, { wait: async value => waits.push(value), fetchImpl: async (url, options) => { + calls++; assert.equal(url.href, "https://dev.azure.com/azure-sdk/00000000-0000-4000-8000-000000000001/_apis/build/builds/1001/timeline?api-version=7.1"); + assert.equal(options.headers.authorization, "Bearer fixture-token"); assert.equal(options.redirect, "error"); + if (calls === 1) throw new Error("Lost response"); + if (calls === 2) return new Response("", { status: 503 }); + return Response.json({ records: [timelineRecord] }); + } }); + assert.equal(result.valid, true); assert.equal(calls, 3); assert.deepEqual(waits, [1000, 2000]); + waits.length = 0; calls = 0; + await readShardAttempts(timelineEnv, { wait: async value => waits.push(value), fetchImpl: async () => ++calls === 1 ? + new Response("", { status: 429, headers: { "retry-after": "5" } }) : Response.json({ records: [timelineRecord] }) }); + assert.equal(calls, 2); assert.deepEqual(waits, [5000]); + calls = 0; + await assert.rejects(readShardAttempts(timelineEnv, { wait: async () => {}, fetchImpl: async () => { calls++; throw new Error("Offline"); } })); + assert.equal(calls, 3); + for (const status of [302, 401, 403]) { + calls = 0; + await assert.rejects(readShardAttempts(timelineEnv, { wait: () => assert.fail("Permanent failures do not retry"), + fetchImpl: async () => { calls++; return new Response("", { status }); } })); + assert.equal(calls, 1); + } +}); + +test("invalid timeline configuration cannot send the build token to another endpoint", async () => { + for (const collection of ["https://dev.azure.com:444/azure-sdk/", "https://dev.azure.com.attacker.example/azure-sdk/", + "http://dev.azure.com/azure-sdk/", "https://user:password@dev.azure.com/azure-sdk/", "https://dev.azure.com/azure-sdk/?extra=1"]) { + await assert.rejects(readShardAttempts({ ...timelineEnv, SYSTEM_COLLECTIONURI: collection }, { fetchImpl: () => assert.fail("Invalid endpoint reached fetch") })); + } +}); \ No newline at end of file diff --git a/eng/common/scripts/login-to-github.ps1 b/eng/common/scripts/login-to-github.ps1 index c3553b91a208..6680f89bf2f0 100644 --- a/eng/common/scripts/login-to-github.ps1 +++ b/eng/common/scripts/login-to-github.ps1 @@ -29,6 +29,10 @@ When set in Azure DevOps, also exports the variable as an output variable (##vso[task.setvariable ...;isOutput=true]) for downstream jobs/stages. +.PARAMETER TokenRequest + Optional JSON object restricting the installation token's repositories and permissions. + Defaults to GITHUB_TOKEN_REQUEST. When omitted, retains the installation's default scope. + .OUTPUTS Sets environment variables in the current process and exports them to the CI system: - Azure DevOps: sets secret pipeline variables via ##vso logging commands @@ -43,7 +47,8 @@ param( [string[]] $InstallationTokenOwners = @("Azure"), [string] $VariableNamePrefix = "GH_TOKEN", [switch] $AlwaysUseOwnerSuffix, - [switch] $ExportAsOutputVariable + [switch] $ExportAsOutputVariable, + [string] $TokenRequest = $env:GITHUB_TOKEN_REQUEST ) $ErrorActionPreference = 'Stop' @@ -210,16 +215,53 @@ function New-GitHubInstallationToken { [Parameter(Mandatory)] [string] $Jwt, [Parameter(Mandatory)] [string] $InstallationId, [Parameter(Mandatory)] [string] $ApiBase, - [Parameter(Mandatory)] [string] $ApiVersion + [Parameter(Mandatory)] [string] $ApiVersion, + [hashtable] $TokenRequest = @{} ) $headers = Get-Headers -Jwt $Jwt -ApiVersion $ApiVersion $uri = "$ApiBase/app/installations/$InstallationId/access_tokens" - $resp = Invoke-RestMethod -Method Post -Headers $headers -Uri $uri -TimeoutSec 30 -MaximumRetryCount 3 - if (!$resp.token) { throw "Failed to obtain installation access token for installation $InstallationId." } - return $resp.token + $request = @{ + Method = 'Post' + Headers = $headers + Uri = $uri + TimeoutSec = 30 + MaximumRetryCount = 3 + } + if ($TokenRequest.Count -gt 0) { + $request.Body = ConvertTo-Json -InputObject $TokenRequest -Depth 5 -Compress + $request.ContentType = 'application/json' + } + + $resp = Invoke-RestMethod @request + $token = Get-PropertyValue -InputObject $resp -PropertyName 'token' + if (!$token) { throw "Failed to obtain installation access token for installation $InstallationId." } + + $requestedPermissions = Get-PropertyValue -InputObject $TokenRequest -PropertyName 'permissions' + if ($requestedPermissions) { + $grantedPermissions = Get-PropertyValue -InputObject $resp -PropertyName 'permissions' + foreach ($permission in $requestedPermissions.Keys) { + if ((Get-PropertyValue -InputObject $grantedPermissions -PropertyName $permission) -ne $requestedPermissions[$permission]) { + throw "Installation token did not grant requested permission '$permission': '$($requestedPermissions[$permission])'." + } + } + } + return $token } function Invoke-LoginToGitHub { + param( + [string] $TokenRequest, + [string] $VariableNamePrefix = 'GH_TOKEN' + ) + + $tokenRequestBody = @{} + if ($TokenRequest) { + $tokenRequestBody = ConvertFrom-Json -InputObject $TokenRequest -AsHashtable + if ($tokenRequestBody -isnot [System.Collections.IDictionary]) { + throw 'TokenRequest must be a JSON object.' + } + } + Write-Host "Generating GitHub App JWT by signing via Azure Key Vault (no key export)..." $jwt = New-GitHubAppJwt -VaultName $KeyVaultName -KeyName $KeyName -AppId $GitHubAppId @@ -232,7 +274,7 @@ function Invoke-LoginToGitHub { Write-Host "Installation ID resolved: $installationId" Write-Host "Exchanging JWT for installation access token..." - $installationToken = New-GitHubInstallationToken -Jwt $jwt -InstallationId $installationId -ApiBase $GitHubApiBaseUrl -ApiVersion $GitHubApiVersion + $installationToken = New-GitHubInstallationToken -Jwt $jwt -InstallationId $installationId -ApiBase $GitHubApiBaseUrl -ApiVersion $GitHubApiVersion -TokenRequest $tokenRequestBody $variableName = $VariableNamePrefix if ($AlwaysUseOwnerSuffix -or $InstallationTokenOwners.Count -gt 1) { @@ -278,4 +320,4 @@ if ($env:PESTER_TEST_RUN -eq 'true') { return } -Invoke-LoginToGitHub +Invoke-LoginToGitHub -TokenRequest $TokenRequest -VariableNamePrefix $VariableNamePrefix diff --git a/eng/common/scripts/skill-eval-coverage/CHANGELOG.md b/eng/common/scripts/skill-eval-coverage/CHANGELOG.md new file mode 100644 index 000000000000..4c1b76063fab --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +## Unreleased + +- Keep Tools-only workflow tests outside the synced package and run the complete + shared test suite in an isolated consumer without Tools workflows or Git history. +- Clarify consumer-owned configuration and remove Tools-relative documentation links. +- Use the verified `aka.ms/azsdk/eval-dashboard` short link in coverage comments. +- Add a deterministic warning-only check for changed skills and associated + skill-local Evals, including nested benchmark suites without pipeline configuration. +- Keep existing Vally lint failures independent and blocking. +- Add a trusted reporter that creates or updates one PR comment for both coverage + outcomes, with the Eval dashboard, authoring guide and source-run links. The + reporter never executes PR code with write credentials or runs model Evals. +- Require confirmed PR relevance before creating a comment; unknown scope can + update an existing coverage comment but cannot create a new one. +- Isolate reporter unit tests from Actions annotations and the real job summary. +- Include the coverage/lint build result, attempt and skill revision in PR comments, + separately from the coverage outcome and historical dashboard results. +- Keep the original blocking lint selectors independent of coverage configuration. +- Match Vally's nonempty string-array multi-turn contract instead of object turns. +- Use Node 24 consistently for lint, coverage checks and trusted PR reporting. +- Pin workflow actions directly to exact commits, run checker tests in their own + read-only workflow, and limit the trusted reporter to a sparse default-branch checkout. +- Use a frozen pnpm 12.6.0 parser install isolated from consuming workspaces. +- State that presence does not assess semantic coverage or prove behavioral correctness. +- Announce in coverage-warning comments that the warning will become a build + blocker by November 9, 2026, while allowing authors to proceed during the grace + period if other required checks pass. Track the separate enforcement change in + [#17402](https://github.com/Azure/azure-sdk-tools/issues/17402). diff --git a/eng/common/scripts/skill-eval-coverage/README.md b/eng/common/scripts/skill-eval-coverage/README.md new file mode 100644 index 000000000000..b927f42f3772 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/README.md @@ -0,0 +1,166 @@ +# Skill Eval coverage + +A deterministic, warning-only check for the skills affected by a pull request. +It discovers Evals inside each skill's folder and reads them as **data**. It does not install Vally, +start Copilot/MCP, execute commands from Evals, or use model quota. +Initial scope is the skill-coverage portion of +[#16345](https://github.com/Azure/azure-sdk-tools/issues/16345), not MCP-tool or +mock-handler coverage enforcement. + +Azure SDK Tools wires this package into its workflows. Syncing `eng/common` alone +does not enable the check in another repository; each consumer supplies its own +configuration and workflows. + +## What happens on a PR + +1. The existing **Skill Evaluations** workflow keeps its independent, blocking + Vally lint job and its original PR path scope. A read-only coverage job lists + changed files on every PR update, including updates that revert all skill + changes. Unrelated diffs skip skill-tree reads and do not create comments. + Vally lint runs for changes to skills, its dependency project or the source + workflow, regardless of coverage warnings. These original lint selectors are + fixed independently of the PR's coverage configuration. If scope detection fails, lint + still runs rather than being silently skipped. +2. Changes anywhere inside a skill, including its references and Evals, select + that skill. Renames select both paths. Removing the last Eval warns; removing + the whole skill skips it. An existing Eval need not change in the same PR. +3. The checker inventories the PR's immutable head tree and parses candidate + Eval YAML in that skill's folder. Presence needs at least one named stimulus + with a nonempty prompt or a nonempty `turns` array of nonempty strings. + Arbitrary fixture YAML, placeholders, invalid YAML, symlinks and oversized files + do not count. This is a minimum presence check, + **not** full Vally schema validation, routing quality, or behavioral grading. + It does not read pipeline configuration or verify that a runner selects the Eval. + When `turns` is provided, a standalone `prompt` does not repair invalid turns. +4. The check summary and warning annotations explain uncovered skills. Coverage + gaps exit successfully; unreadable data, failed requests and broken tooling + fail the checker rather than produce a false coverage pass. There is no + blanket `continue-on-error` and no change to the lint job's outcome. +5. After that workflow completes, **Skill Eval coverage comment** recomputes the + result with trusted default-branch code and updates one bot-owned PR comment. + **Both coverage-present and coverage-missing comments contain the Eval + dashboard, authoring-guide and source-run links.** The comment also records the + coverage/lint build result, attempt and exact skill revision. This build is not + a model-backed Eval run. An unreadable report is + labeled unknown, never passing. Creating a new comment requires confirmed + relevant changes. If changed-file scope is unavailable or exceeds the supported + limit, only an existing coverage comment can be updated to unknown; otherwise + the unknown result appears in the check summary without creating a comment. + If a later push removes all relevant skills, + the existing comment is updated to explain that there are no remaining skills + to check. Unrelated PRs do not get new comments. + +The dashboard shows historical, published evaluation results. It requires the +existing corporate network/sign-in access and does not prove this PR passed an +Eval. This check neither queries the dashboard nor uploads results to it. + +## Warning grace period + +Coverage-warning comments announce that the warning will become a build blocker +by **November 9, 2026**. Until enforcement is enabled, missing Eval coverage is +warning-only: authors may proceed if all other required checks pass. Add or fix +the affected Evals before the deadline. + +This change announces the timeline only. It does not enable blocking behavior or +an automatic date-based switch. The November 9 checkpoint requires a separate +change to promote coverage warnings to errors after reviewing the remaining gaps. +Existing lint failures and tooling errors remain blocking throughout the grace period. +Track that enforcement change in +[#17402](https://github.com/Azure/azure-sdk-tools/issues/17402). + +## Files and configuration + +```text +coverage.ts # Shared inventory, minimal YAML checks, safe Markdown +check.ts # Local working-tree/Git-diff CLI and check summary +github.ts # Immutable GitHub reads and trusted comment updates +test/ # Unit tests, real Git fixtures and fake HTTP tests +pnpm-lock.yaml # Exactly one frozen runtime dependency: yaml +``` + +The consumer owns its configuration in `.github/skill-eval-coverage.json`; it is +not included in the shared package. That configuration names the skills root, +source workflow and approved report links. No deployment addresses are embedded +in shared code, and no pipeline paths or runner globs are needed. + +For each skill, candidates are `eval.yaml` or `eval.yml` at the skill root, or +`eval.yaml`, `eval.yml`, `*.eval.yaml` and `*.eval.yml` recursively under `evals/` +or `evaluate/evals/`. Paths inside `fixtures/` are excluded. This includes nested +TypeSpec-author benchmark Evals without knowing which pipeline executes them. +Whitespace in a valid Eval path does not change presence; runner compatibility +is a separate concern. A coverage-config or shared skills-config change inventories +all skills. Pipeline-only edits do not create coverage comments. + +The read-only PR job reads configuration from the PR checkout. The writer uses +default-branch configuration and recomputes independently, so PR code or a +modified report cannot choose the destination or content of a privileged comment. + +## Verification and coverage levels + +In Azure SDK Tools, `.github/workflows/skill-eval-coverage-tests.yml` tests the +checker and reporting contracts separately from the workflow that checks actual +PR skills. Tools-only workflow assertions live in +`eng/common-tests/skill-eval-coverage/`, outside the synced package. A consumer +regression runs the entire shared test suite without Tools workflows or Git history. +Actions use exact commit SHA pins with readable version comments. Checker +dependency installs use pnpm 12.6.0 with a frozen lockfile, disabled lifecycle +scripts and isolation from consuming repositories' workspaces. + +This PR implements **Eval presence only**. A separate advisory agentic workflow can +compare declared skill capabilities with Eval metadata, prompts and graders and +identify likely semantic gaps. That second level does not run Evals and is not +implemented or made blocking here. Presence is not a semantic quality score or +evidence that the Evals pass. + +## Run locally + +Requires **Node 24.14.1+**, **pnpm 12.6.0** and Git. From the repository root: + +```sh +pnpm --dir eng/common/scripts/skill-eval-coverage --ignore-workspace install --pm-on-fail=ignore --frozen-lockfile --ignore-scripts +node --test eng/common/scripts/skill-eval-coverage/test/*.test.ts +node eng/common/scripts/skill-eval-coverage/check.ts --all +node eng/common/scripts/skill-eval-coverage/check.ts --base origin/main --head HEAD +node eng/common/scripts/skill-eval-coverage/check.ts --all --json +``` + +`--all` includes tracked and untracked working files. `--base` checks the committed +head tree using a three-dot Git diff and needs the base's history available locally; +missing history is an error. Neither command posts a comment. The Node tests use +temporary Git repositories and fully fake API transport; they make no live writes. +Reporter unit tests isolate Actions reporting variables so synthetic scenarios +cannot add annotations or fixture reports to the real workflow summary. + +In Azure SDK Tools, also run +`node --test eng/common-tests/skill-eval-coverage/*.test.ts` to check its workflow +contracts and the isolated consumer regression. + +Keep the existing local Vally lint step as documented in +[eng/skill-eval](https://github.com/Azure/azure-sdk-tools/blob/main/eng/skill-eval/README.md). +The coverage checker does not +replace lint or execute the model-backed Eval suites. + +## Comment security and rollout + +The `pull_request` job has only `contents: read` and `pull-requests: read`. The +separate `workflow_run` reporter has `contents: read`, `actions: read` and +`pull-requests: write`. Its sparse checkout includes only the default branch's +coverage package and report configuration. It installs its frozen parser with +lifecycle scripts disabled, and never downloads PR artifacts, +checks out PR code, evaluates YAML commands, or imports code from the PR. + +The reporter validates the source workflow ID/path/repository, open PR, fork, +branch and exact head SHA. It rechecks the head just before writing and skips +obsolete/cancelled runs. Fork runs with no `pull_requests` metadata are resolved +using commit associations and independently verified PR identity; ambiguous +associations are skipped. Trees/file lists have completeness and size checks, +requests have deadlines, redirects are forbidden, and source text is escaped +before rendering. It updates only a `github-actions[bot]` comment with the stable +coverage marker. A concurrency group serializes updates for each source branch. + +The reporter first becomes active **after its workflow and code reach the default +branch**. Its live fork permissions and comment delivery still need a normal PR +smoke check after merge; local fake-transport tests do not prove production delivery. +No new PAT, service connection or repository settings are needed. Blocking +enforcement is planned for the November 9 checkpoint in a separate change; +this change does not make missing coverage fatal. diff --git a/eng/common/scripts/skill-eval-coverage/check.ts b/eng/common/scripts/skill-eval-coverage/check.ts new file mode 100644 index 000000000000..db5111ba3ac0 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/check.ts @@ -0,0 +1,120 @@ +// Runs the same warning-only coverage check locally against working files or a Git diff. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { + CONFIG_PATH, MAX_FILE_BYTES, checkCoverage, hasCoverageWarnings, parseConfig, + renderReport, repositoryPath, +} from "./coverage.ts"; +import type { CoverageReport, RepositoryFile, RepositorySnapshot } from "./coverage.ts"; + +function git(root: string, args: string[]): string { + return execFileSync("git", ["--literal-pathspecs", "-C", root, ...args], { + encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 30000, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }); +} + +function commit(root: string, ref: string): string { + const sha = git(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).trim(); + if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error("Expected a Git commit."); + return sha; +} + +export function gitSnapshot(root: string, ref: string): RepositorySnapshot { + const sha = commit(root, ref); + const files = git(root, ["ls-tree", "-rlz", sha]).split("\0").filter(Boolean).flatMap((line) => { + const match = /^(\d+) (\w+) ([a-f0-9]+)\s+(\d+|-)\t([\s\S]+)$/.exec(line); + if (!match || match[2] !== "blob") return []; + return [{ path: repositoryPath(match[5]), mode: match[1], sha: match[3], size: Number(match[4]) }]; + }); + const entries = new Map(files.map((file) => [file.path, file])); + return { + files, + async read(file) { + const entry = entries.get(repositoryPath(file)); + if (!entry) return undefined; + if (entry.size > MAX_FILE_BYTES) throw new Error("Repository file exceeds the read limit."); + return git(root, ["show", `${sha}:${file}`]); + }, + }; +} + +export function workingSnapshot(root: string): RepositorySnapshot { + const files: RepositoryFile[] = []; + const names = new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) + .split("\0").filter(Boolean)); + for (const name of names) { + const file = repositoryPath(name); + let stat: fs.Stats; + try { stat = fs.lstatSync(path.join(root, file)); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (stat.isFile() || stat.isSymbolicLink()) { + files.push({ path: file, mode: stat.isSymbolicLink() ? "120000" : "100644", size: stat.size }); + } + } + const realRoot = fs.realpathSync(root); + const entries = new Map(files.map((file) => [file.path, file])); + return { + files, + async read(file) { + const entry = entries.get(repositoryPath(file)); + if (!entry) return undefined; + if (entry.mode === "120000" || (entry.size ?? 0) > MAX_FILE_BYTES) { + throw new Error("Repository file is a link or exceeds the read limit."); + } + const target = fs.realpathSync(path.join(root, file)); + const relative = path.relative(realRoot, target); + if (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative)) { + throw new Error("Repository file resolves outside the checkout."); + } + return fs.readFileSync(target, "utf8"); + }, + }; +} + +export function writeSummary(report: CoverageReport | null, markdown: string): void { + console.log(markdown); + if (process.env.GITHUB_ACTIONS === "true") { + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown); + const escape = (value: string) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + if (report && hasCoverageWarnings(report)) { + for (const skill of report.skills.filter((item) => item.status === "missing")) { + console.log(`::warning::${escape(`Skill ${skill.name}: ${skill.issues.join(" ")}`)}`); + } + } + } +} + +export async function main(argv: string[]): Promise { + const { values } = parseArgs({ args: argv, options: { + "repo-root": { type: "string", default: "." }, + base: { type: "string" }, head: { type: "string", default: "HEAD" }, + all: { type: "boolean", default: false }, json: { type: "boolean", default: false }, + } }); + if (values.all && values.base) throw new Error("Choose --all or --base, not both."); + const root = path.resolve(values["repo-root"]!); + const snapshot = values.base ? gitSnapshot(root, values.head!) : workingSnapshot(root); + const configText = await snapshot.read(CONFIG_PATH); + if (!configText) throw new Error("Coverage configuration is missing."); + const config = parseConfig(configText); + const changed = values.base ? git(root, ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", + "--name-only", "-z", `${commit(root, values.base)}...${commit(root, values.head!)}`, "--"]) + .split("\0").filter(Boolean) : null; + const report = await checkCoverage(snapshot, config, changed); + if (values.json) console.log(JSON.stringify(report, null, 2)); + else writeSummary(report, renderReport(report, { config })); + // Missing coverage is intentionally not a nonzero exit. Data/transport failures still fail. +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch(() => { + console.error("Skill coverage could not be determined. Check configuration, file access and Git history."); + process.exitCode = 1; + }); +} diff --git a/eng/common/scripts/skill-eval-coverage/coverage.ts b/eng/common/scripts/skill-eval-coverage/coverage.ts new file mode 100644 index 000000000000..66c95c1e6cd9 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/coverage.ts @@ -0,0 +1,229 @@ +// Checks skill-local Eval presence using repository files as data only. +import path from "node:path"; +import { parseDocument } from "yaml"; + +export const CONFIG_PATH = ".github/skill-eval-coverage.json"; +export const COMMENT_MARKER = ""; +export const MAX_FILE_BYTES = 256 * 1024; + +export interface CoverageConfig { + skillsRoot: string; + workflow: string; + dashboardUrl: string; + authoringGuideUrl: string; +} + +export interface RepositoryFile { + path: string; + mode: string; + sha?: string; + size?: number; +} + +export interface RepositorySnapshot { + files: RepositoryFile[]; + read: (file: string) => Promise; +} + +export interface SkillCoverage { + name: string; + skillFile: string; + status: "present" | "missing" | "deleted"; + evals: string[]; + issues: string[]; +} + +export interface CoverageReport { + skills: SkillCoverage[]; + fullScan: boolean; +} + +export function repositoryPath(value: unknown): string { + if (typeof value !== "string" || !value || value.length > 4096 || + value.includes("\\") || /[\x00-\x1f\x7f]/.test(value) || + value.split("/").some((part) => !part || part === "." || part === "..") || + /^[A-Za-z]:/.test(value)) { + throw new Error("Expected a repository-relative path without traversal or control characters."); + } + return value; +} + +function webUrl(value: unknown): string { + if (typeof value !== "string") throw new Error("Expected an HTTPS report link."); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || /[\r\n]/.test(value)) { + throw new Error("Expected an HTTPS report link without credentials."); + } + return url.href; +} + +export function parseConfig(text: string): CoverageConfig { + const value = JSON.parse(text); + if (!record(value)) throw new Error("Expected coverage configuration."); + return { + skillsRoot: repositoryPath(value.skillsRoot), + workflow: repositoryPath(value.workflow), + dashboardUrl: webUrl(value.dashboardUrl), + authoringGuideUrl: webUrl(value.authoringGuideUrl), + }; +} + +function yamlData(text: string): unknown { + if (Buffer.byteLength(text, "utf8") > MAX_FILE_BYTES) throw new Error("File is too large."); + const document = parseDocument(text, { version: "1.2", uniqueKeys: true }); + if (document.errors.length) throw new Error("Invalid YAML."); + return document.toJS({ maxAliasCount: 100 }); +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function regular(file: RepositoryFile | undefined): boolean { + return file?.mode === "100644" || file?.mode === "100755"; +} + +function nonempty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function hasStimulus(value: unknown): boolean { + if (!record(value) || !Array.isArray(value.stimuli)) return false; + return value.stimuli.some((stimulus: unknown) => record(stimulus) && nonempty(stimulus.name) && + (stimulus.turns === undefined ? nonempty(stimulus.prompt) : + Array.isArray(stimulus.turns) && stimulus.turns.length > 0 && stimulus.turns.every(nonempty) && + (stimulus.prompt === undefined || typeof stimulus.prompt === "string"))); +} + +export async function checkCoverage( + snapshot: RepositorySnapshot, + config: CoverageConfig, + changedPaths: string[] | null, +): Promise { + const files = new Map(snapshot.files.map((file) => [repositoryPath(file.path), file])); + const root = config.skillsRoot + "/"; + const changes = new Set((changedPaths ?? []).map(repositoryPath)); + const fullScan = changedPaths === null || changes.has(CONFIG_PATH) || + changes.has(root + ".vally.yaml"); + const names = new Set(); + if (fullScan) { + for (const file of files.keys()) { + if (file.startsWith(root) && file.slice(root.length).split("/").length === 2 && + file.endsWith("/SKILL.md")) names.add(file.slice(root.length).split("/")[0]); + } + } + for (const file of changes) { + if (file.startsWith(root) && file.slice(root.length).includes("/")) { + names.add(file.slice(root.length).split("/")[0]); + } + } + + const skills: SkillCoverage[] = []; + for (const name of [...names].sort()) { + const directory = root + name + "/"; + const skillFile = directory + "SKILL.md"; + if (!files.has(skillFile)) { + if (changes.has(skillFile)) { + skills.push({ name, skillFile, status: "deleted", evals: [], issues: [] }); + } + continue; // Shared guides and fixture folders without SKILL.md are not skills. + } + const item: SkillCoverage = { name, skillFile, status: "missing", evals: [], issues: [] }; + if (!regular(files.get(skillFile))) { + item.issues.push("SKILL.md must be a regular file, not a symbolic link."); + skills.push(item); + continue; + } + const candidates = [...files.keys()].filter((file) => file.startsWith(directory) && + /(?:^|\/)(?:eval|[^/]+\.eval)\.ya?ml$/.test(file.slice(directory.length)) && + (file === directory + "eval.yaml" || file === directory + "eval.yml" || + file.startsWith(directory + "evals/") || file.startsWith(directory + "evaluate/evals/")) && + !file.slice(directory.length).split("/").includes("fixtures")); + for (const file of candidates.sort()) { + if (!regular(files.get(file)) || (files.get(file)?.size ?? 0) > MAX_FILE_BYTES) { + item.issues.push(`Eval must be a regular file of at most ${MAX_FILE_BYTES} bytes: ${file}`); + continue; + } + const text = await snapshot.read(file); + if (text === undefined) throw new Error("An Eval file could not be read."); + try { + if (!hasStimulus(yamlData(text))) throw new Error("No nonempty stimulus."); + } catch { + item.issues.push(`Invalid Eval YAML or no named, nonempty stimulus: ${file}`); + continue; + } + item.evals.push(file); + } + if (item.evals.length) item.status = "present"; + else item.issues.push("No associated Eval with a named, nonempty stimulus was found in the skill folder."); + skills.push(item); + } + return { skills, fullScan }; +} + +export function hasCoverageWarnings(report: CoverageReport): boolean { + return report.skills.some((skill) => skill.status === "missing"); +} + +function html(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">") + .replace(/[|\[\]`*_()!@\\]/g, (character) => `&#${character.charCodeAt(0)};`) + .replace(/\r?\n/g, " "); +} + +function link(label: string, value: string): string { + return `[${label}](${webUrl(value).replace(/\(/g, "%28").replace(/\)/g, "%29")})`; +} + +export interface ReportLinks { + config: CoverageConfig; + sourceUrl?: string; + runUrl?: string; + build?: { id: number; attempt: number; result: string; headSha: string }; +} + +export function renderReport(report: CoverageReport | null, links: ReportLinks): string { + const active = report?.skills.filter((skill) => skill.status !== "deleted") ?? []; + const heading = report === null ? "⚠️ Skill Eval coverage could not be determined" : + hasCoverageWarnings(report) ? "⚠️ Skill Eval coverage missing" : + active.length ? "✅ Skill Eval coverage present" : "ℹ️ No remaining affected skills to check"; + const lines = [COMMENT_MARKER, `## ${heading}`, ""]; + if (report && hasCoverageWarnings(report)) { + lines.push( + "> **Grace period:** This warning will become a build blocker by **November 9, 2026**.", + "> For now, missing Eval coverage is warning-only; you may proceed if all other required checks pass.", + "> Add or fix the affected Evals before the deadline.", ""); + } + if (links.build) { + const build = links.build; + lines.push(`**Coverage/lint build:** ${links.runUrl ? link(`#${build.id}`, links.runUrl) : `#${build.id}`} · ` + + `**Build result:** ${html(build.result)} · **Attempt:** ${build.attempt}`, + `**Skill revision:** ${html(build.headSha)}`, ""); + } + if (!report) { + lines.push("The coverage check could not read all required data. Do not treat this as a coverage pass."); + } else { + if (report.fullScan) lines.push("Full inventory: coverage configuration or shared skills configuration changed.", ""); + lines.push("| Skill | Coverage | Associated Eval |", "| --- | --- | --- |"); + for (const skill of report.skills.slice(0, 50)) { + const status = skill.status === "present" ? "Present" : skill.status === "deleted" ? "Skipped — skill deleted" : "Missing"; + const evals = skill.evals.slice(0, 3).map((file) => links.sourceUrl ? + link(html(path.posix.basename(file)), `${links.sourceUrl}/${file.split("/").map(encodeURIComponent).join("/")}`) : + `${html(file)}`).join(", ") || "—"; + lines.push(`| ${html(skill.name)} | ${status} | ${evals} |`); + } + if (!report.skills.length) lines.push("| — | No skill changes | — |"); + for (const skill of report.skills.filter((item) => item.status === "missing").slice(0, 30)) { + lines.push("", `**${html(skill.name)}**: ${html(skill.issues.join(" "))}`); + } + if (report.skills.length > 50) lines.push("", "Only the first 50 skills are shown; run the local JSON inventory for the complete list."); + } + const body = lines.join("\n").slice(0, 50000); + return body + "\n\n" + [ + `${link("Eval-authoring guide", links.config.authoringGuideUrl)} · ${link("View Eval dashboard", links.config.dashboardUrl)}`, + "Dashboard access requires corporate access/sign-in. It shows published historical results, not validation of this PR.", + "This checks associated Eval presence only. Semantic coverage of the skill's capabilities is not assessed.", + ...(links.runUrl ? [link("Check run", links.runUrl)] : []), + "Coverage warnings are **nonblocking**. This check does not run Evals or prove behavioral correctness. Existing lint failures remain blocking.", + ].join("\n\n") + "\n"; +} diff --git a/eng/common/scripts/skill-eval-coverage/github.ts b/eng/common/scripts/skill-eval-coverage/github.ts new file mode 100644 index 000000000000..b3a46a14e795 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/github.ts @@ -0,0 +1,276 @@ +// Reads immutable PR files as data and updates a bot-owned comment from a trusted workflow. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + CONFIG_PATH, COMMENT_MARKER, MAX_FILE_BYTES, checkCoverage, parseConfig, + renderReport, repositoryPath, +} from "./coverage.ts"; +import type { CoverageConfig, CoverageReport, RepositoryFile, RepositorySnapshot } from "./coverage.ts"; +import { writeSummary } from "./check.ts"; + +export class GitHubApi { + private readonly apiUrl: string; + private readonly token: string; + private readonly fetcher: typeof fetch; + + constructor( + apiUrl: string, + token: string, + fetcher: typeof fetch = fetch, + ) { + const url = new URL(apiUrl); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + throw new Error("Expected a trusted HTTPS GitHub API URL."); + } + if (!token) throw new Error("A GitHub token is required."); + this.apiUrl = apiUrl; + this.token = token; + this.fetcher = fetcher; + } + + async request(route: string, method = "GET", body?: unknown): Promise { + if (!route.startsWith("/repos/") || route.includes("://") || /[\r\n]/.test(route)) { + throw new Error("Unexpected GitHub API route."); + } + const response = await this.fetcher(this.apiUrl.replace(/\/$/, "") + route, { + method, redirect: "error", signal: AbortSignal.timeout(30000), + headers: { Authorization: `Bearer ${this.token}`, Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) throw new Error(`GitHub API request failed (${response.status}).`); + const text = await response.text(); + if (Buffer.byteLength(text) > 16 * 1024 * 1024) throw new Error("GitHub response is too large."); + return JSON.parse(text) as T; + } + + async pages(route: string): Promise { + const items: T[] = []; + for (let page = 1; page <= 30; page++) { + const batch = await this.request(`${route}${route.includes("?") ? "&" : "?"}per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error("Expected a paginated GitHub list."); + items.push(...batch); + if (batch.length < 100) return items; + } + throw new Error("GitHub list exceeds the bounded pagination limit."); + } +} + +interface PullRequest { + number: number; + state: string; + changed_files: number; + head: { sha: string; ref: string; repo: { full_name: string } | null }; + base: { repo: { full_name: string } }; +} + +interface WorkflowRun { + id: number; + run_attempt: number; + event: string; + status: string; + conclusion: string; + path: string; + workflow_id: number; + head_sha: string; + head_branch: string; + head_repository: { full_name: string } | null; + repository: { full_name: string }; + pull_requests: { number: number }[]; +} + +interface Comment { + id: number; + body: string; + user: { login: string; type: string }; +} + +function repositoryName(value: string): string { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) throw new Error("Invalid GitHub repository."); + return value; +} + +function positive(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function sha(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{40}$/.test(value); +} + +function sameRepository(a: string | undefined, b: string): boolean { + return typeof a === "string" && a.toLowerCase() === b.toLowerCase(); +} + +export async function remoteSnapshot(api: GitHubApi, repo: string, head: string): Promise { + if (!sha(head)) throw new Error("Invalid PR head revision."); + const route = `/repos/${repositoryName(repo)}`; + const tree = await api.request<{ truncated: boolean; tree: (RepositoryFile & { type: string })[] }>( + `${route}/git/trees/${head}?recursive=1`); + if (tree.truncated || !Array.isArray(tree.tree)) throw new Error("Incomplete GitHub file inventory."); + const files = tree.tree.filter((entry) => entry.type === "blob").map((entry) => ({ + path: repositoryPath(entry.path), mode: entry.mode, sha: entry.sha, size: entry.size, + })); + const entries = new Map(files.map((file) => [file.path, file])); + const cache = new Map>(); + return { + files, + async read(file) { + const entry = entries.get(repositoryPath(file)); + if (!entry) return undefined; + if (!sha(entry.sha) || (entry.size ?? Infinity) > MAX_FILE_BYTES || entry.mode === "120000") { + throw new Error("Repository file is a link, too large, or has no immutable blob ID."); + } + if (!cache.has(entry.sha)) cache.set(entry.sha, (async () => { + const blob = await api.request<{ encoding: string; content: string; size: number; sha: string }>( + `${route}/git/blobs/${entry.sha}`); + if (blob.sha !== entry.sha || blob.encoding !== "base64" || blob.size > MAX_FILE_BYTES || + typeof blob.content !== "string" || blob.content.length > MAX_FILE_BYTES * 2) { + throw new Error("Unexpected GitHub blob response."); + } + const bytes = Buffer.from(blob.content, "base64"); + if (bytes.length !== blob.size || bytes.length > MAX_FILE_BYTES) throw new Error("Invalid blob size."); + return bytes.toString("utf8"); + })()); + return cache.get(entry.sha)!; + }, + }; +} + +async function pull(api: GitHubApi, repo: string, number: number): Promise { + if (!positive(number)) throw new Error("Invalid PR number."); + const value = await api.request(`/repos/${repo}/pulls/${number}`); + if (value.number !== number || !sameRepository(value.base?.repo?.full_name, repo) || !sha(value.head?.sha)) { + throw new Error("Unexpected PR identity."); + } + return value; +} + +async function getChangedPaths(api: GitHubApi, repo: string, pr: PullRequest): Promise { + if (!Number.isSafeInteger(pr.changed_files) || pr.changed_files < 0 || pr.changed_files >= 3000) { + throw new Error("PR file inventory exceeds the supported limit."); + } + const changed = await api.pages<{ filename: string; previous_filename?: string }>( + `/repos/${repo}/pulls/${pr.number}/files`); + if (changed.length !== pr.changed_files) throw new Error("Incomplete PR changed-file inventory."); + return changed.flatMap((file) => file.previous_filename ? + [repositoryPath(file.filename), repositoryPath(file.previous_filename)] : [repositoryPath(file.filename)]); +} + +export function shouldRunLint(paths: string[]): boolean { + // The original blocking lint scope must not depend on PR-editable coverage configuration. + return paths.some((file) => file.startsWith(".github/skills/") || + file.startsWith("eng/skill-eval/") || file === ".github/workflows/skill-eval.yml"); +} + +function affectsCoverage(paths: string[], config: CoverageConfig): boolean { + return paths.some((file) => file.startsWith(config.skillsRoot + "/") || + file === CONFIG_PATH); +} + +export async function checkPullRequest( + api: GitHubApi, repo: string, pr: PullRequest, config: CoverageConfig, changedPaths?: string[], +): Promise { + const paths = changedPaths ?? await getChangedPaths(api, repo, pr); + if (!affectsCoverage(paths, config)) { + return { skills: [], fullScan: false }; + } + return checkCoverage(await remoteSnapshot(api, repo, pr.head.sha), config, paths); +} + +export async function upsertComment( + api: GitHubApi, repo: string, number: number, body: string, createIfMissing = true, +): Promise { + const comments = await api.pages(`/repos/${repo}/issues/${number}/comments`); + const existing = comments.find((comment) => comment.user?.type === "Bot" && + comment.user.login === "github-actions[bot]" && comment.body?.startsWith(COMMENT_MARKER)); + if (existing) { + if (existing.body !== body) await api.request(`/repos/${repo}/issues/comments/${existing.id}`, "PATCH", { body }); + } else if (createIfMissing) { + await api.request(`/repos/${repo}/issues/${number}/comments`, "POST", { body }); + } else return false; + return true; +} + +export async function reportCompletedRun( + api: GitHubApi, repo: string, runId: number, config: CoverageConfig, serverUrl: string, +): Promise<"posted" | "unknown" | "skipped"> { + repositoryName(repo); + if (!positive(runId)) throw new Error("Invalid source workflow run."); + const run = await api.request(`/repos/${repo}/actions/runs/${runId}`); + const workflow = await api.request<{ id: number }>(`/repos/${repo}/actions/workflows/${encodeURIComponent(path.posix.basename(config.workflow))}`); + if (run.id !== runId || run.workflow_id !== workflow.id || run.path !== config.workflow || + !sameRepository(run.repository?.full_name, repo) || run.event !== "pull_request" || + run.status !== "completed" || !sha(run.head_sha) || !positive(run.run_attempt) || + ["cancelled", "skipped"].includes(run.conclusion)) return "skipped"; + + // GitHub can omit pull_requests for fork runs. Only accept one independently matched open PR. + const numbers = run.pull_requests?.length ? run.pull_requests.map((pr) => pr.number) : + (await api.pages<{ number: number }>(`/repos/${repo}/commits/${run.head_sha}/pulls`)).map((pr) => pr.number); + const candidates: PullRequest[] = []; + for (const number of new Set(numbers)) { + const pr = await pull(api, repo, number); + if (pr.state === "open" && pr.head.sha === run.head_sha && pr.head.ref === run.head_branch && + sameRepository(pr.head.repo?.full_name, run.head_repository?.full_name ?? "")) candidates.push(pr); + } + if (candidates.length !== 1) return "skipped"; + const pr = candidates[0]; + let report: CoverageReport | null = null; + let relevant = false; + try { + const changedPaths = await getChangedPaths(api, repo, pr); + relevant = affectsCoverage(changedPaths, config); + report = await checkPullRequest(api, repo, pr, config, changedPaths); + relevant = report.skills.length > 0; + } + catch { console.warn("Coverage could not be determined; the PR comment will not claim a pass."); } + const sourceUrl = `${serverUrl}/${repo}/blob/${run.head_sha}`; + const runUrl = `${serverUrl}/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt}`; + const body = renderReport(report, { config, sourceUrl, runUrl, + build: { id: run.id, attempt: run.run_attempt, result: run.conclusion, headSha: run.head_sha } }); + writeSummary(report, body); + // Check again after all reads: an old run cannot overwrite a comment for a newer PR head. + const current = await pull(api, repo, pr.number); + if (current.state !== "open" || current.head.sha !== run.head_sha) return "skipped"; + if (!await upsertComment(api, repo, pr.number, body, relevant)) return "skipped"; + return report ? "posted" : "unknown"; +} + +export async function main(argv: string[]): Promise { + if (argv.some((arg) => arg !== "--post-comment") || argv.length > 1) throw new Error("Unknown reporting option."); + const publish = argv.includes("--post-comment"); + const repo = repositoryName(process.env.GITHUB_REPOSITORY ?? ""); + const config = parseConfig(fs.readFileSync(CONFIG_PATH, "utf8")); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH ?? "", "utf8")); + const api = new GitHubApi(process.env.GITHUB_API_URL ?? "https://api.github.com", process.env.GITHUB_TOKEN ?? ""); + const serverUrl = process.env.GITHUB_SERVER_URL ?? "https://github.com"; + if (publish) { + if (process.env.GITHUB_EVENT_NAME !== "workflow_run" || process.env.GITHUB_ACTIONS !== "true") { + throw new Error("Only a trusted workflow_run reporter may post comments."); + } + console.log(`Coverage comment: ${await reportCompletedRun(api, repo, event.workflow_run?.id, config, serverUrl)}`); + } else { + if (process.env.GITHUB_EVENT_NAME !== "pull_request") throw new Error("Expected a pull_request event."); + const pr = await pull(api, repo, event.number); + if (pr.head.sha !== event.pull_request?.head?.sha || pr.state !== "open") return; + const changedPaths = await getChangedPaths(api, repo, pr); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `run_lint=${shouldRunLint(changedPaths)}\n`); + } + const report = await checkPullRequest(api, repo, pr, config, changedPaths); + const current = await pull(api, repo, pr.number); + if (current.head.sha !== pr.head.sha || current.state !== "open") return; + writeSummary(report, renderReport(report, { + config, sourceUrl: `${serverUrl}/${repo}/blob/${pr.head.sha}`, + runUrl: `${serverUrl}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`, + })); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch(() => { + console.error("Skill coverage reporting failed. No passing coverage result should be inferred."); + process.exitCode = 1; + }); +} diff --git a/eng/common/scripts/skill-eval-coverage/package.json b/eng/common/scripts/skill-eval-coverage/package.json new file mode 100644 index 000000000000..3d728c1df19f --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/package.json @@ -0,0 +1,17 @@ +{ + "name": "@azure-tools/skill-eval-coverage", + "version": "1.0.0", + "private": true, + "description": "Deterministic skill Eval coverage checks and trusted GitHub PR reporting; does not install or run an evaluator.", + "type": "module", + "packageManager": "pnpm@12.6.0", + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "test": "node --test test/*.test.ts" + }, + "dependencies": { + "yaml": "2.9.1" + } +} diff --git a/eng/common/scripts/skill-eval-coverage/pnpm-lock.yaml b/eng/common/scripts/skill-eval-coverage/pnpm-lock.yaml new file mode 100644 index 000000000000..0b4c5c00ce52 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + yaml: + specifier: 2.9.1 + version: 2.9.1 + +packages: + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + yaml@2.9.1: {} diff --git a/eng/common/scripts/skill-eval-coverage/test/coverage.test.ts b/eng/common/scripts/skill-eval-coverage/test/coverage.test.ts new file mode 100644 index 000000000000..816e2a3e5dc4 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/test/coverage.test.ts @@ -0,0 +1,233 @@ +// Covers skill-local Eval presence, malformed inputs, scope, and safe PR Markdown. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CONFIG_PATH, MAX_FILE_BYTES, checkCoverage, hasCoverageWarnings, parseConfig, + renderReport, repositoryPath, +} from "../coverage.ts"; +import type { CoverageConfig, RepositorySnapshot } from "../coverage.ts"; + +const config: CoverageConfig = { + skillsRoot: ".github/skills", + workflow: ".github/workflows/skill-eval.yml", + dashboardUrl: "https://dashboard.example.test/", + authoringGuideUrl: "https://github.com/test/repo/blob/main/guide.md", +}; +const stimulus = "name: example\nstimuli:\n - name: trigger-example\n prompt: Test the skill\n graders: [{type: skill-invocation}]\n"; +const skill = (name: string) => `.github/skills/${name}/SKILL.md`; +const evaluation = (name: string) => `.github/skills/${name}/evals/eval.yaml`; + +function snapshot(extra: Record, modes: Record = {}): RepositorySnapshot { + const contents = extra; + return { + files: Object.entries(contents).map(([file, text]) => ({ + path: file, mode: modes[file] ?? "100644", size: Buffer.byteLength(text), + })), + async read(file) { return contents[file]; }, + }; +} + +test("new uncovered skill warns; an unchanged existing Eval covers a skill-only edit", async () => { + const report = await checkCoverage(snapshot({ + [skill("missing")]: "# Skill", [skill("present")]: "# Skill", [evaluation("present")]: stimulus, + [skill("unrelated")]: "# Skill", + }), config, [skill("missing"), skill("present")]); + assert.deepEqual(report.skills.map((item) => [item.name, item.status]), [["missing", "missing"], ["present", "present"]]); + assert.equal(hasCoverageWarnings(report), true); + assert.equal(report.fullScan, false); +}); + +test("reference-only and Eval deletion changes check the owning skill", async () => { + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill" }), config, + [".github/skills/example/references/guide.md", evaluation("example")]); + assert.equal(report.skills.length, 1); + assert.equal(report.skills[0].status, "missing"); +}); + +test("deleted skills are skipped; renamed skills check old and new paths", async () => { + const report = await checkCoverage(snapshot({ [skill("new")]: "# Skill", [evaluation("new")]: stimulus }), + config, [skill("old"), evaluation("old"), skill("new"), evaluation("new")]); + assert.deepEqual(report.skills.map((item) => [item.name, item.status]), [["new", "present"], ["old", "deleted"]]); + assert.equal(hasCoverageWarnings(report), false); +}); + +test("guides and fixture directories without SKILL.md do not invent skills", async () => { + const report = await checkCoverage(snapshot({}), config, [".github/skills/eval-authoring/README.md"]); + assert.deepEqual(report.skills, []); +}); + +test("skill-local Eval presence does not read or depend on an execution pipeline", async () => { + const source = snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: stimulus }); + const read = source.read; + source.read = async (file) => { + assert.ok(file.startsWith(".github/skills/example/"), "presence discovery must start from the skill folder"); + return read(file); + }; + const report = await checkCoverage(source, config, [skill("example")]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [evaluation("example")]); +}); + +test("alternate benchmark is covered by its skill-local Eval folder", async () => { + const file = ".github/skills/alternate/evaluate/evals/001001.eval.yaml"; + const report = await checkCoverage(snapshot({ [skill("alternate")]: "# Skill", [file]: stimulus }), config, [skill("alternate")]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [file]); +}); + +test("nested skill Evals count without pipeline registration", async () => { + const file = ".github/skills/example/evals/nested/behavior.eval.yaml"; + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [file]: stimulus }), config, [skill("example")]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [file]); +}); + +for (const [name, file] of [ + ["example", ".github/skills/example/eval.yaml"], + ["example", ".github/skills/example/eval.yml"], + ["example", ".github/skills/example/evals/.hidden.eval.yaml"], + ["example", ".github/skills/example/evals/starter case.eval.yaml"], + ["example", ".github/skills/example/evals/starter\u00a0case.eval.yaml"], + ["example skill", ".github/skills/example skill/evals/starter.eval.yaml"], + ["alternate suite", ".github/skills/alternate suite/evaluate/evals/starter.eval.yml"], +]) { + test(`associated Eval presence is independent of runner path restrictions: ${file}`, async () => { + const report = await checkCoverage(snapshot({ [skill(name)]: "# Skill", [file]: stimulus }), config, [skill(name)]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [file]); + }); +} + +test("fixtures, unrelated YAML and another skill's Evals cannot provide presence", async () => { + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", + ".github/skills/example/evals/fixtures/starter.eval.yaml": stimulus, + ".github/skills/example/evals/tspconfig.yaml": stimulus, + ".github/skills/example/references/eval.yaml": stimulus, + [skill("other")]: "# Other", [evaluation("other")]: stimulus, + }), config, [skill("example")]); + assert.equal(report.skills[0].status, "missing"); + assert.deepEqual(report.skills[0].evals, []); +}); + +for (const [label, text] of Object.entries({ + empty: "", comment: "# placeholder", malformed: "stimuli: [", duplicate: "stimuli: []\nstimuli: []", + noStimuli: "name: placeholder", blankPrompt: "stimuli: [{name: example, prompt: ' '}]", + noName: "stimuli: [{prompt: hello}]", wrongShape: "stimuli: hello", +})) { + test(`${label} Eval is not coverage`, async () => { + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + assert.equal(report.skills[0].status, "missing"); + assert.match(report.skills[0].issues.join(" "), /Invalid Eval YAML/); + }); +} + +test("YAML anchors and multi-turn stimuli are accepted without running commands", async () => { + const text = "shared: &shared 'hello'\nstimuli:\n - name: turns\n turns: [*shared, *shared]\n"; + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + assert.equal(report.skills[0].status, "present"); +}); + +test("a discoverable Eval with nonempty string turns provides coverage without a prompt", async () => { + const text = "stimuli: [{name: conversation, turns: ['Inspect the input', 'Now continue']}]\n"; + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [evaluation("example")]); +}); + +for (const turns of ["[{prompt: first}, {prompt: second}]", "['first', {prompt: second}]", "['first', ' ']", "[]", "first"]) { + for (const prompt of ["", "prompt: 'A standalone prompt cannot repair invalid turns', "]) { + test(`invalid multi-turn shape cannot provide coverage: ${prompt}${turns}`, async () => { + const text = `stimuli: [{name: conversation, ${prompt}turns: ${turns}}]\n`; + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + assert.equal(report.skills[0].status, "missing"); + assert.deepEqual(report.skills[0].evals, []); + }); + } +} + +test("normal repeated grading aliases do not falsely invalidate an Eval", async () => { + const text = "grader: &g {type: output-contains, config: {value: yes}}\nstimuli:\n" + + Array.from({ length: 40 }, (_, index) => ` - name: case-${index}\n prompt: test\n graders: [*g]\n`).join(""); + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + assert.equal(report.skills[0].status, "present"); +}); + +test("unrelated YAML fixtures and symlink Evals cannot claim coverage", async () => { + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: stimulus, + ".github/skills/example/evals/fixtures/tspconfig.yaml": stimulus, + }, { [evaluation("example")]: "120000" }), config, [skill("example")]); + assert.equal(report.skills[0].status, "missing"); + assert.deepEqual(report.skills[0].evals, []); +}); + +test("oversized Evals and symlink skill definitions warn without following links", async () => { + const report = await checkCoverage(snapshot({ + [skill("large")]: "# Skill", [evaluation("large")]: "x".repeat(MAX_FILE_BYTES + 1), + [skill("link")]: "outside/skill", [evaluation("link")]: stimulus, + }, { [skill("link")]: "120000" }), config, [skill("large"), skill("link")]); + assert.ok(report.skills.every((item) => item.status === "missing")); +}); + +for (const change of [CONFIG_PATH, ".github/skills/.vally.yaml"]) { + test(`shared configuration change inventories all skills: ${change}`, async () => { + const report = await checkCoverage(snapshot({ [skill("first")]: "# Skill", [skill("second")]: "# Skill" }), config, [change]); + assert.equal(report.fullScan, true); + assert.equal(report.skills.length, 2); + }); +} + +test("pipeline-only changes do not select skills or alter Eval presence", async () => { + const report = await checkCoverage(snapshot({ "pipeline.yml": "not: discovery", [skill("example")]: "# Skill", + [evaluation("example")]: stimulus }), config, ["pipeline.yml"]); + assert.equal(report.fullScan, false); + assert.deepEqual(report.skills, []); +}); + +test("transport errors propagate rather than treating an unread Eval as covered", async () => { + const source = snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: stimulus }); + const original = source.read; + source.read = async (file) => { if (file === evaluation("example")) throw new Error("network"); return original(file); }; + await assert.rejects(checkCoverage(source, config, [skill("example")]), /network/); +}); + +test("both coverage outcomes, deleted-only, and unknown results keep dashboard and guide links", async () => { + for (const text of [stimulus, ""]) { + const report = await checkCoverage(snapshot({ [skill("example")]: "# Skill", [evaluation("example")]: text }), config, [skill("example")]); + const markdown = renderReport(report, { config, sourceUrl: "https://github.com/test/repo/blob/head", runUrl: "https://github.com/test/repo/actions/runs/1" }); + assert.ok(markdown.includes(config.dashboardUrl)); + assert.ok(markdown.includes(config.authoringGuideUrl)); + assert.match(markdown, text ? /coverage present/ : /coverage missing/); + assert.match(markdown, /does not run Evals/); + assert.match(markdown, /Semantic coverage of the skill's capabilities is not assessed/); + assert.doesNotMatch(markdown, /Evals passed/); + if (text) assert.doesNotMatch(markdown, /This warning will become|you may proceed/); + else { + assert.match(markdown, /This warning will become a build blocker by \*\*November 9, 2026\*\*/); + assert.match(markdown, /warning-only; you may proceed if all other required checks pass/); + assert.match(markdown, /Add or fix the affected Evals before the deadline/); + } + } + const unknown = renderReport(null, { config }); + assert.match(unknown, /could not be determined/); + assert.ok(unknown.includes(config.dashboardUrl)); + assert.doesNotMatch(unknown, /This warning will become|you may proceed/); + const deleted = await checkCoverage(snapshot({}), config, [skill("deleted")]); + assert.doesNotMatch(renderReport(deleted, { config }), /This warning will become|you may proceed/); +}); + +test("untrusted paths are escaped in Markdown, including mentions and image syntax", async () => { + const name = "bad![x](@team)|"; + const report = await checkCoverage(snapshot({ [skill(name)]: "# Skill" }), config, [skill(name)]); + const markdown = renderReport(report, { config }); + assert.doesNotMatch(markdown, /@team|!\[x\]|/); + assert.match(markdown, /@team/); +}); + +test("configuration and repository paths reject traversal, commands and insecure links", () => { + for (const invalid of ["../outside", "/absolute", "a//b", "a\\b", "a\nb", "C:/file"]) { + assert.throws(() => repositoryPath(invalid)); + } + assert.deepEqual(parseConfig(JSON.stringify(config)), config); + assert.throws(() => parseConfig(JSON.stringify({ ...config, dashboardUrl: "http://host/" }))); + assert.throws(() => parseConfig("null")); +}); diff --git a/eng/common/scripts/skill-eval-coverage/test/github.test.ts b/eng/common/scripts/skill-eval-coverage/test/github.test.ts new file mode 100644 index 000000000000..58ae90021585 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/test/github.test.ts @@ -0,0 +1,312 @@ +// Exercises the real API adapter and trusted comment lifecycle with fully fake HTTP transport. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { after, before, test } from "node:test"; +import { COMMENT_MARKER, parseConfig } from "../coverage.ts"; +import type { CoverageConfig } from "../coverage.ts"; +import { GitHubApi, checkPullRequest, remoteSnapshot, reportCompletedRun, shouldRunLint, upsertComment } from "../github.ts"; + +const reportingEnvironment = { + GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, + GITHUB_STEP_SUMMARY: process.env.GITHUB_STEP_SUMMARY, +}; +before(() => { + // Fake API scenarios must never annotate or append reports to the real Actions job. + process.env.GITHUB_ACTIONS = "false"; + delete process.env.GITHUB_STEP_SUMMARY; +}); +after(() => { + for (const [name, value] of Object.entries(reportingEnvironment)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +const repo = "test/repo"; +const head = "a".repeat(40); +const config: CoverageConfig = { + skillsRoot: ".github/skills", workflow: ".github/workflows/skill-eval.yml", + dashboardUrl: "https://dashboard.example.test/", authoringGuideUrl: "https://github.com/test/repo/blob/main/guide.md", +}; +const skillFile = ".github/skills/example/SKILL.md"; +const evalFile = ".github/skills/example/evals/eval.yaml"; +const stimulus = "stimuli: [{name: trigger-example, prompt: test}]\n"; + +function fixture(covered = true) { + const files = new Map([ + [skillFile, "# Example"], ...(covered ? [[evalFile, stimulus] as [string, string]] : []), + ]); + const entries = () => [...files.entries()].map(([file, text]) => ({ + path: file, mode: "100644", type: "blob", size: Buffer.byteLength(text), + sha: createHash("sha1").update(text).digest("hex"), + })); + const pr = { number: 7, state: "open", changed_files: 1, + head: { sha: head, ref: "feature", repo: { full_name: "contributor/repo" } }, + base: { repo: { full_name: repo } } }; + const run = { id: 42, run_attempt: 1, event: "pull_request", status: "completed", conclusion: "success", + path: config.workflow, workflow_id: 12, head_sha: head, head_branch: "feature", + repository: { full_name: repo }, head_repository: { full_name: "contributor/repo" }, pull_requests: [{ number: 7 }] }; + const comments: { id: number; body: string; user: { login: string; type: string } }[] = []; + const writes: { route: string; method: string; body: { body: string } }[] = []; + const calls: string[] = []; + const changed: { filename: string; previous_filename?: string }[] = [{ filename: skillFile }]; + let prReads = 0; + let failBlob = false; + let failChanges = false; + let truncated = false; + let staleBeforeWrite = false; + const api = new GitHubApi("https://api.github.test", "fake-token-not-real", (async (url, init) => { + assert.equal(new URL(String(url)).origin, "https://api.github.test"); + assert.equal(init?.redirect, "error"); + assert.ok(init?.signal); + assert.equal((init?.headers as Record).Authorization, "Bearer fake-token-not-real"); + const route = decodeURIComponent(new URL(String(url)).pathname); + calls.push(route); + if (init?.method !== "GET") { + assert.match(route, /^\/repos\/test\/repo\/issues\/(7\/comments|comments\/\d+)$/); + writes.push({ route, method: init!.method!, body: JSON.parse(String(init!.body)) }); + return Response.json({ id: 99 }); + } + if (route === "/repos/test/repo/actions/runs/42") return Response.json(run); + if (route === "/repos/test/repo/actions/workflows/skill-eval.yml") return Response.json({ id: 12 }); + if (route === "/repos/test/repo/pulls/7") { + prReads++; + return Response.json(staleBeforeWrite && prReads > 1 ? { ...pr, head: { ...pr.head, sha: "b".repeat(40) } } : pr); + } + if (route === "/repos/test/repo/pulls/7/files") { + return failChanges ? new Response("private changed-file response", { status: 503 }) : Response.json(changed); + } + if (route === "/repos/test/repo/commits/" + head + "/pulls") return Response.json([{ number: 7 }]); + if (route === "/repos/test/repo/git/trees/" + head) return Response.json({ truncated, tree: entries() }); + if (route.startsWith("/repos/test/repo/git/blobs/")) { + if (failBlob) return new Response("secret server response must not be logged", { status: 503 }); + const entry = entries().find((item) => route.endsWith(item.sha)); + assert.ok(entry, "Only immutable, inventoried blob IDs may be read"); + const bytes = Buffer.from(files.get(entry.path)!); + return Response.json({ sha: entry.sha, size: bytes.length, encoding: "base64", content: bytes.toString("base64") }); + } + if (route === "/repos/test/repo/issues/7/comments") return Response.json(comments); + assert.fail("Unexpected API route: " + route); + }) as typeof fetch); + return { api, run, pr, files, comments, calls, writes, changed, + failBlob: () => { failBlob = true; }, failChanges: () => { failChanges = true; }, + truncate: () => { truncated = true; }, + makeStale: () => { staleBeforeWrite = true; } }; +} + +for (const covered of [true, false]) { + test(`${covered ? "covered" : "missing"} fork skill creates a comment containing the dashboard and authoring links`, async () => { + const f = fixture(covered); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "posted"); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].method, "POST"); + const body = f.writes[0].body.body; + assert.ok(body.startsWith(COMMENT_MARKER)); + assert.ok(body.includes(config.dashboardUrl)); + assert.ok(body.includes(config.authoringGuideUrl)); + assert.match(body, covered ? /coverage present/ : /coverage missing/); + assert.match(body, /published historical results/); + assert.match(body, /actions\/runs\/42\/attempts\/1/); + assert.match(body, /Coverage\/lint build.*42/); + assert.match(body, /Build result.*success/); + assert.match(body, /Attempt.*1/); + assert.ok(body.includes(head)); + assert.ok(f.calls.some((route) => route.includes("/git/trees/"))); + assert.equal(f.calls.some((route) => route.includes("/git/blobs/")), covered); + if (covered) assert.doesNotMatch(body, /This warning will become|you may proceed/); + else { + assert.match(body, /This warning will become a build blocker by \*\*November 9, 2026\*\*/); + assert.match(body, /warning-only; you may proceed if all other required checks pass/); + } + }); +} + +test("a failing lint source run still gets the independent coverage comment", async () => { + const f = fixture(); + f.run.conclusion = "failure"; + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "posted"); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body.body, /Build result.*failure/); +}); + +test("warning becomes covered by updating the same bot-owned comment", async () => { + const f = fixture(); + f.comments.push({ id: 21, body: COMMENT_MARKER + "\ncoverage missing", user: { login: "github-actions[bot]", type: "Bot" } }); + await reportCompletedRun(f.api, repo, 42, config, "https://github.com"); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].method, "PATCH"); + assert.equal(f.writes[0].route, "/repos/test/repo/issues/comments/21"); + assert.match(f.writes[0].body.body, /coverage present/); + assert.doesNotMatch(f.writes[0].body.body, /This warning will become|you may proceed/); +}); + +test("a later push reverting all skill changes clears the existing comment and retains dashboard links", async () => { + const f = fixture(); + f.changed.splice(0, 1, { filename: "README.md" }); + f.comments.push({ id: 21, body: COMMENT_MARKER + "\ncoverage missing", user: { login: "github-actions[bot]", type: "Bot" } }); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "posted"); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].method, "PATCH"); + assert.match(f.writes[0].body.body, /No remaining affected skills/); + assert.ok(f.writes[0].body.body.includes(config.dashboardUrl)); + assert.ok(f.writes[0].body.body.includes(config.authoringGuideUrl)); +}); + +for (const empty of [false, true]) { + test(`${empty ? "empty" : "unrelated"} diff does not create a new coverage comment`, async () => { + const f = fixture(); + f.changed.splice(0, 1, ...(empty ? [] : [{ filename: "README.md" }])); + f.pr.changed_files = f.changed.length; + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "skipped"); + assert.equal(f.writes.length, 0); + assert.ok(f.calls.every((route) => !route.includes("/git/"))); + }); +} + +test("pipeline-only changes neither read skill trees nor create coverage comments", async () => { + const f = fixture(); + f.changed.splice(0, 1, { filename: "eng/common/pipelines/skill-eval.yml" }); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "skipped"); + assert.equal(f.writes.length, 0); + assert.ok(f.calls.every((route) => !route.includes("/git/"))); +}); + +test("lint selection preserves original paths even when skill coverage is missing", () => { + for (const file of [skillFile, evalFile, ".github/skills/example/references/guide.md", + "eng/skill-eval/package-lock.json", config.workflow]) { + assert.equal(shouldRunLint([file]), true, file); + } + for (const file of ["README.md", "tools/example/file.ts", "eng/common/scripts/skill-eval-coverage/check.ts"]) { + assert.equal(shouldRunLint([file]), false, file); + } + assert.equal(shouldRunLint([]), false); +}); + +test("nondefault coverage configuration cannot disable the original blocking lint selectors", async () => { + const alternate = parseConfig(JSON.stringify({ ...config, + skillsRoot: ".github/other-skills", workflow: ".github/workflows/other-coverage.yml" })); + const f = fixture(); + const originalPaths = [skillFile, "eng/skill-eval/package-lock.json", ".github/workflows/skill-eval.yml"]; + assert.deepEqual((await checkPullRequest(f.api, repo, f.pr, alternate, [skillFile])).skills, []); + for (const file of originalPaths) { + assert.equal(shouldRunLint([file, ".github/skill-eval-coverage.json"]), true, file); + } + assert.equal(shouldRunLint([".github/other-skills/example/SKILL.md"]), false); + assert.equal(shouldRunLint([alternate.workflow]), false); +}); + +test("human and unrelated bot comments with a copied marker are not overwritten", async () => { + const f = fixture(); + f.comments.push({ id: 20, body: COMMENT_MARKER, user: { login: "person", type: "User" } }, + { id: 21, body: COMMENT_MARKER, user: { login: "other-bot[bot]", type: "Bot" } }); + await upsertComment(f.api, repo, 7, COMMENT_MARKER + "\nnew report"); + assert.equal(f.writes[0].method, "POST"); +}); + +test("identical comment body is not rewritten", async () => { + const f = fixture(); + f.comments.push({ id: 21, body: COMMENT_MARKER, user: { login: "github-actions[bot]", type: "Bot" } }); + await upsertComment(f.api, repo, 7, COMMENT_MARKER); + assert.equal(f.writes.length, 0); +}); + +test("fork runs with empty pull_requests resolve via commit association and independently verified head", async () => { + const f = fixture(); + f.run.pull_requests = []; + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "posted"); + assert.ok(f.calls.some((route) => route.includes("/commits/"))); +}); + +for (const [label, mutate] of [ + ["wrong workflow ID", (f: ReturnType) => { f.run.workflow_id = 33; }], + ["wrong workflow path", (f: ReturnType) => { f.run.path = ".github/workflows/other.yml"; }], + ["wrong repository", (f: ReturnType) => { f.run.repository.full_name = "other/repo"; }], + ["non-PR run", (f: ReturnType) => { f.run.event = "push"; }], + ["cancelled run", (f: ReturnType) => { f.run.conclusion = "cancelled"; }], + ["unfinished run", (f: ReturnType) => { f.run.status = "in_progress"; }], + ["closed PR", (f: ReturnType) => { f.pr.state = "closed"; }], + ["old PR head", (f: ReturnType) => { f.pr.head.sha = "b".repeat(40); }], + ["foreign fork", (f: ReturnType) => { f.pr.head.repo.full_name = "foreign/repo"; }], + ["foreign branch", (f: ReturnType) => { f.pr.head.ref = "different"; }], +] as const) { + test(`${label} cannot write a PR comment`, async () => { + const f = fixture(); + mutate(f); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "skipped"); + assert.equal(f.writes.length, 0); + }); +} + +test("a push during checking prevents an obsolete comment update", async () => { + const f = fixture(); + f.makeStale(); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "skipped"); + assert.equal(f.writes.length, 0); +}); + +for (const reason of ["blob failure", "truncated tree"]) { + test(`${reason} produces an unknown result with dashboard, never a pass`, async () => { + const f = fixture(); + if (reason === "blob failure") f.failBlob(); + else f.truncate(); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), "unknown"); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body.body, /could not be determined/); + assert.doesNotMatch(f.writes[0].body.body, /coverage present|secret server response/); + assert.ok(f.writes[0].body.body.includes(config.dashboardUrl)); + }); +} + +for (const reason of ["changed-file failure", "incomplete PR file list", "3000-file PR"]) { + for (const existing of [false, true]) { + test(`${reason} with unknown scope ${existing ? "updates only the existing comment" : "cannot create a comment"}`, async () => { + const f = fixture(); + f.changed.splice(0, 1, { filename: "README.md" }); + if (reason === "changed-file failure") f.failChanges(); + else f.pr.changed_files = reason === "3000-file PR" ? 3000 : 2; + if (existing) f.comments.push({ id: 21, body: COMMENT_MARKER + "\ncoverage missing", + user: { login: "github-actions[bot]", type: "Bot" } }); + assert.equal(await reportCompletedRun(f.api, repo, 42, config, "https://github.com"), existing ? "unknown" : "skipped"); + assert.equal(f.writes.length, existing ? 1 : 0); + assert.ok(f.calls.every((route) => !route.includes("/git/"))); + if (existing) { + assert.equal(f.writes[0].method, "PATCH"); + assert.equal(f.writes[0].route, "/repos/test/repo/issues/comments/21"); + assert.match(f.writes[0].body.body, /could not be determined/); + assert.doesNotMatch(f.writes[0].body.body, /coverage present|private changed-file response/); + assert.ok(f.writes[0].body.body.includes(config.dashboardUrl)); + } + }); + } +} + +test("API errors expose status only and never raw response or credentials", async () => { + const api = new GitHubApi("https://api.github.test", "private-test-token", (async () => + new Response("private-test-token secret response", { status: 403 })) as typeof fetch); + await assert.rejects(api.request("/repos/test/repo/pulls/7"), (error: Error) => { + assert.equal(error.message, "GitHub API request failed (403)."); + return true; + }); + await assert.rejects(api.request("https://attacker.test/"), /Unexpected/); + assert.throws(() => new GitHubApi("http://api.github.test", "token")); +}); + +test("API list pagination retrieves a later bot comment", async () => { + const calls: string[] = []; + const api = new GitHubApi("https://api.github.test", "fake", (async (url) => { + calls.push(String(url)); + const page = new URL(String(url)).searchParams.get("page"); + return Response.json(page === "1" ? Array.from({ length: 100 }, (_, id) => ({ id })) : [{ id: 101 }]); + }) as typeof fetch); + assert.equal((await api.pages("/repos/test/repo/issues/7/comments")).length, 101); + assert.equal(calls.length, 2); +}); + +test("remote files are read only from immutable blob IDs and reject links", async () => { + const f = fixture(); + const source = await remoteSnapshot(f.api, repo, head); + assert.equal(await source.read(skillFile), "# Example"); + assert.equal(await source.read(skillFile), "# Example"); + assert.equal(f.calls.filter((route) => route.includes("/git/blobs/")).length, 1); + await assert.rejects(remoteSnapshot(f.api, repo, "main"), /revision/); +}); diff --git a/eng/common/scripts/skill-eval-coverage/test/integration.test.ts b/eng/common/scripts/skill-eval-coverage/test/integration.test.ts new file mode 100644 index 000000000000..78eb9ef7a171 --- /dev/null +++ b/eng/common/scripts/skill-eval-coverage/test/integration.test.ts @@ -0,0 +1,158 @@ +// Uses real temporary Git histories to verify portable CLI and reporting contracts. +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { parse } from "yaml"; +import { CONFIG_PATH, checkCoverage, parseConfig } from "../coverage.ts"; +import type { CoverageReport } from "../coverage.ts"; +import { gitSnapshot, workingSnapshot } from "../check.ts"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const checker = path.join(packageRoot, "check.ts"); +const skill = (name: string) => `.github/skills/${name}/SKILL.md`; +const evaluation = (name: string) => `.github/skills/${name}/evals/eval.yaml`; +const config = { skillsRoot: ".github/skills", workflow: ".github/workflows/skill-eval.yml", + dashboardUrl: "https://dashboard.example.test/", authoringGuideUrl: "https://github.com/test/repo/guide" }; + +function write(root: string, file: string, text: string): void { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); +} + +function git(root: string, ...args: string[]): string { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 30000, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" } }); +} + +function fixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "skill-coverage-git-")); + git(root, "init", "--quiet"); + git(root, "config", "user.email", "test@example.test"); + git(root, "config", "user.name", "Coverage Test"); + git(root, "config", "core.autocrlf", "false"); + write(root, CONFIG_PATH, JSON.stringify(config)); + write(root, skill("old"), "# Old skill\n"); + write(root, evaluation("old"), "stimuli: [{name: test, prompt: test}]\n"); + git(root, "add", "."); + git(root, "commit", "--quiet", "-m", "fixture base"); + return root; +} + +function cli(root: string, ...args: string[]) { + return spawnSync(process.execPath, [checker, "--repo-root", root, ...args], { + encoding: "utf8", timeout: 30000, + env: { ...process.env, GITHUB_ACTIONS: "false", GITHUB_STEP_SUMMARY: "" }, + }); +} + +test("real Git rename reports old deletion and checks the new skill", () => { + const root = fixture(); + try { + const base = git(root, "rev-parse", "HEAD").trim(); + fs.renameSync(path.join(root, ".github/skills/old"), path.join(root, ".github/skills/new")); + git(root, "add", "-A"); git(root, "commit", "--quiet", "-m", "rename skill"); + const result = cli(root, "--base", base, "--json"); + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout) as CoverageReport; + assert.deepEqual(report.skills.map((item) => [item.name, item.status]), [["new", "present"], ["old", "deleted"]]); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("removing the last Eval exits successfully with a warning and dashboard link", () => { + const root = fixture(); + try { + const base = git(root, "rev-parse", "HEAD").trim(); + fs.unlinkSync(path.join(root, evaluation("old"))); + git(root, "add", "-A"); git(root, "commit", "--quiet", "-m", "remove eval"); + const result = cli(root, "--base", base); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /coverage missing/); + assert.ok(result.stdout.includes(config.dashboardUrl)); + assert.match(result.stdout, /This warning will become a build blocker by \*\*November 9, 2026\*\*/); + assert.match(result.stdout, /warning-only; you may proceed if all other required checks pass/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("working inventory includes new untracked skill files and no historical commits are needed", async () => { + const root = fixture(); + try { + write(root, skill("untracked"), "# New\n"); + const result = cli(root, "--all", "--json"); + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout) as CoverageReport; + assert.equal(report.skills.find((item) => item.name === "untracked")?.status, "missing"); + const snapshot = gitSnapshot(root, "HEAD"); + assert.equal((await checkCoverage(snapshot, parseConfig(JSON.stringify(config)), null)).skills[0].status, "present"); + assert.ok(workingSnapshot(root).files.some((file) => file.path === skill("untracked"))); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("missing Git base is an error, not a false passing empty diff", () => { + const root = fixture(); + try { assert.notEqual(cli(root, "--base", "not-a-ref").status, 0); } + finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("the standalone checker runs in a consumer without Tools pipelines or shared Eval scripts", () => { + const root = fixture(); + try { + const consumer = path.join(root, "checker"); + fs.mkdirSync(consumer); + for (const file of ["coverage.ts", "check.ts", "github.ts", "package.json"]) { + fs.copyFileSync(path.join(packageRoot, file), path.join(consumer, file)); + } + fs.symlinkSync(path.join(packageRoot, "node_modules"), path.join(consumer, "node_modules"), + process.platform === "win32" ? "junction" : "dir"); + const result = spawnSync(process.execPath, [path.join(consumer, "check.ts"), "--repo-root", root, "--all", "--json"], { + encoding: "utf8", timeout: 30000, + env: { ...process.env, GITHUB_ACTIONS: "false", GITHUB_STEP_SUMMARY: "" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).skills.map((item: { name: string; status: string }) => [item.name, item.status]), [["old", "present"]]); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("Eval presence is independent of pipeline availability and collector path restrictions", async () => { + const root = fixture(); + try { + fs.unlinkSync(path.join(root, evaluation("old"))); + const file = ".github/skills/old/evals/starter case.eval.yaml"; + write(root, file, "stimuli: [{name: test, prompt: test}]\n"); + const report = await checkCoverage(workingSnapshot(root), parseConfig(JSON.stringify(config)), [skill("old")]); + assert.equal(report.skills[0].status, "present"); + assert.deepEqual(report.skills[0].evals, [file]); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("reporter unit tests do not write fixture summaries or workflow commands in Actions", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "skill-coverage-test-output-")); + try { + const summary = path.join(root, "summary.md"); + const original = "Real workflow summary must remain unchanged.\n"; + fs.writeFileSync(summary, original); + const env: NodeJS.ProcessEnv = { ...process.env, GITHUB_ACTIONS: "true", GITHUB_STEP_SUMMARY: summary }; + delete env.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ["--test", "--test-reporter=tap", path.join(packageRoot, "test/github.test.ts")], { + env, encoding: "utf8", timeout: 30000, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(summary, "utf8"), original); + assert.doesNotMatch(result.stdout + result.stderr, /::(?:warning|error|notice)::/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("checker package has exactly one frozen parser dependency and no local workspace links", () => { + const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")); + const lock = parse(fs.readFileSync(path.join(packageRoot, "pnpm-lock.yaml"), "utf8")); + assert.deepEqual(manifest.dependencies, { yaml: "2.9.1" }); + assert.equal(manifest.packageManager, "pnpm@12.6.0"); + assert.deepEqual(Object.keys(lock.importers), ["."]); + assert.deepEqual(lock.importers["."].dependencies, { yaml: { specifier: "2.9.1", version: "2.9.1" } }); + assert.deepEqual(Object.keys(lock.packages), ["yaml@2.9.1"]); + assert.equal(lock.packages["yaml@2.9.1"].resolution.integrity, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="); + assert.equal(fs.existsSync(path.join(packageRoot, "package-lock.json")), false); +}); diff --git a/eng/common/scripts/tests/Get-PackageApprovalStatus.Tests.ps1 b/eng/common/scripts/tests/Get-PackageApprovalStatus.Tests.ps1 index d6a744e3d937..6f86148236a6 100644 --- a/eng/common/scripts/tests/Get-PackageApprovalStatus.Tests.ps1 +++ b/eng/common/scripts/tests/Get-PackageApprovalStatus.Tests.ps1 @@ -125,9 +125,31 @@ Describe "Get-PackageApprovalStatus.ps1" { $messages = @(& $scriptPath -PackageInfoFiles $packageInfoPath 6>&1) | ForEach-Object { "$_" } - [Array]::IndexOf($messages, "API Review Hub") | Should BeLessThan ([Array]::IndexOf($messages, "APIView")) - [Array]::IndexOf($messages, "APIView") | Should BeLessThan ([Array]::IndexOf($messages, "Overall")) - ($messages -join [Environment]::NewLine) | Should Match "Overall\r?\n Status: APPROVED\r?\n Source: APIView\r?\n Reason: approved" + foreach ($heading in @("Approval Results", "API Review Hub", "APIView", "Overall")) { + @($messages | Where-Object { $_ -eq "$heading (azure-test 1.0.0)" }).Count | Should Be 1 + } + [Array]::IndexOf($messages, "API Review Hub (azure-test 1.0.0)") | Should BeLessThan ([Array]::IndexOf($messages, "APIView (azure-test 1.0.0)")) + [Array]::IndexOf($messages, "APIView (azure-test 1.0.0)") | Should BeLessThan ([Array]::IndexOf($messages, "Overall (azure-test 1.0.0)")) + ($messages -join [Environment]::NewLine) | Should Match "Overall \(azure-test 1\.0\.0\)\r?\n Status: APPROVED\r?\n Source: APIView\r?\n Reason: approved" + } + + It "includes each package name and version in all summary headings when checking multiple packages" { + $secondPackageInfoPath = Join-Path $TestDrive "azure-other.json" + @{ + Name = "azure-other" + Version = "2.0.0b1" + } | ConvertTo-Json | Set-Content $secondPackageInfoPath + $global:AzSdkOutput = '{"operation_status":"Succeeded","result":{"isApproved":true,"finalSource":"APIView","reason":"approved","reviewHub":{"isApproved":false,"reason":"repositoryNotSupported","statusCode":200},"apiView":{"isApproved":true,"reason":"approved","statusCode":200}}}' + + $messages = @(& $scriptPath -PackageInfoFiles @($packageInfoPath, $secondPackageInfoPath) 6>&1) | + ForEach-Object { "$_" } + + foreach ($package in @("azure-test 1.0.0", "azure-other 2.0.0b1")) { + foreach ($heading in @("Approval Results", "API Review Hub", "APIView", "Overall")) { + @($messages | Where-Object { $_ -eq "$heading ($package)" }).Count | Should Be 1 + } + } + $global:CapturedAzSdkInvocations.Count | Should Be 2 } It "includes raw output when azsdk returns malformed output" { diff --git a/eng/emitter-package-lock.json b/eng/emitter-package-lock.json index f37ef1f72785..214bde95a8c6 100644 --- a/eng/emitter-package-lock.json +++ b/eng/emitter-package-lock.json @@ -6,34 +6,34 @@ "": { "name": "dist/src/index.js", "dependencies": { - "@azure-tools/typespec-python": "0.63.8" + "@azure-tools/typespec-python": "0.63.11" }, "devDependencies": { - "@azure-tools/openai-typespec": "1.28.0", - "@azure-tools/typespec-autorest": "~0.72.0", - "@azure-tools/typespec-azure-core": "~0.72.0", - "@azure-tools/typespec-azure-portal-core": "~0.72.0", - "@azure-tools/typespec-azure-resource-manager": "~0.72.0", - "@azure-tools/typespec-azure-rulesets": "~0.72.0", - "@azure-tools/typespec-client-generator-core": "~0.72.1", + "@azure-tools/openai-typespec": "1.34.1", + "@azure-tools/typespec-autorest": "~0.73.2", + "@azure-tools/typespec-azure-core": "~0.73.0", + "@azure-tools/typespec-azure-portal-core": "~0.73.0", + "@azure-tools/typespec-azure-resource-manager": "~0.73.0", + "@azure-tools/typespec-azure-rulesets": "~0.73.0", + "@azure-tools/typespec-client-generator-core": "~0.73.1", "@azure-tools/typespec-liftr-base": "0.13.0", - "@typespec/compiler": "^1.16.0", - "@typespec/events": "~0.86.0", - "@typespec/http": "^1.16.0", - "@typespec/http-client-python": "0.38.0", - "@typespec/openapi": "^1.16.0", - "@typespec/openapi3": "^1.16.0", - "@typespec/rest": "~0.86.0", - "@typespec/sse": "~0.86.0", - "@typespec/streams": "~0.86.0", - "@typespec/versioning": "~0.86.0", - "@typespec/xml": "~0.86.0" + "@typespec/compiler": "^1.17.0", + "@typespec/events": "~0.87.0", + "@typespec/http": "^1.17.0", + "@typespec/http-client-python": "0.40.0", + "@typespec/openapi": "^1.17.0", + "@typespec/openapi3": "^1.17.0", + "@typespec/rest": "~0.87.0", + "@typespec/sse": "~0.87.0", + "@typespec/streams": "~0.87.0", + "@typespec/versioning": "~0.87.0", + "@typespec/xml": "~0.87.0" } }, "node_modules/@azure-tools/openai-typespec": { - "version": "1.28.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/openai-typespec/-/openai-typespec-1.28.0.tgz", - "integrity": "sha1-rXCoTFopEeA3qKL8/P3Uim0ZuuA=", + "version": "1.34.1", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/openai-typespec/-/openai-typespec-1.34.1.tgz", + "integrity": "sha512-lEkeHLHbHZKDdyvntS4Q51ghFKrn3aF2wDHUFoHhWgBgOqwtE3mFEsnSCfe6Kx1hIcNixvYPX3Rxclc6mBg6oA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -42,26 +42,27 @@ } }, "node_modules/@azure-tools/typespec-autorest": { - "version": "0.72.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-autorest/-/typespec-autorest-0.72.1.tgz", - "integrity": "sha512-pMPbdMh72mXYVE6oKxDfJXV/BmTBMws1s5mm0rdbleyVe7hFSkB1SrK62rGw0DnFZ6YRyQxCjTu4+4a4rxIDyA==", + "version": "0.73.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-autorest/-/typespec-autorest-0.73.2.tgz", + "integrity": "sha512-Qm9m+4Ok2XxLvOm5l9dr6EmTN15ArGaktefSKZ5uNU2lt26RDntj2GP2tkudXjDijwqYGKnGsIU8l5wK9yMOvQ==", "license": "MIT", "dependencies": { + "@azure-tools/typespec-azure-examples": "^0.2.2", "yaml": "^2.9.0" }, "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.72.1", - "@azure-tools/typespec-azure-resource-manager": "^0.72.1", - "@azure-tools/typespec-client-generator-core": "^0.72.2", - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/rest": "^0.86.0", - "@typespec/versioning": "^0.86.0", - "@typespec/xml": "^0.86.0" + "@azure-tools/typespec-azure-core": "^0.73.0", + "@azure-tools/typespec-azure-resource-manager": "^0.73.0", + "@azure-tools/typespec-client-generator-core": "^0.73.0", + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/rest": "^0.87.0", + "@typespec/versioning": "^0.87.0", + "@typespec/xml": "^0.87.0" }, "peerDependenciesMeta": { "@typespec/xml": { @@ -70,34 +71,63 @@ } }, "node_modules/@azure-tools/typespec-azure-core": { - "version": "0.72.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.72.1.tgz", - "integrity": "sha512-6U1JJOguo+opnVwQrZC5rPMz0MLMpeuNZ5g8wflG5MzBl3C9d2VPOtnJzYC7mbfbSKKLyktsp1MjDG1lw3zJkw==", + "version": "0.73.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.73.0.tgz", + "integrity": "sha512-RSiXLLPvBowaDeliOsa2z9ChT4Z6DE5zJVz6wVhpA3Jh5kZgT3irN8Rqtg6BJO9T5TBVLPUPcUgXMQ5X8JFhLQ==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0", - "@typespec/rest": "^0.86.0" + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0", + "@typespec/rest": "^0.87.0" + } + }, + "node_modules/@azure-tools/typespec-azure-examples": { + "version": "0.2.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-examples/-/typespec-azure-examples-0.2.2.tgz", + "integrity": "sha512-7T6JLezY3uMbhCcyLU4rDQACt5eTjJLMMuKgq1ma1tYhoZmLX2yu5TcOUqfFkzkxypWI7JOdG7kgQHKhvPTduQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "picocolors": "^1.1.1", + "yaml": "^2.9.0", + "yargs": "^18.1.0" + }, + "bin": { + "tsp-examples": "dist/src/cli.js", + "tsp-examples-legacy-expand": "dist/src/legacy-expand-cli.js", + "tsp-examples-migrate": "dist/src/migrate-cli.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "^1.17.0" + }, + "peerDependenciesMeta": { + "@typespec/compiler": { + "optional": true + } } }, "node_modules/@azure-tools/typespec-azure-portal-core": { - "version": "0.72.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-portal-core/-/typespec-azure-portal-core-0.72.0.tgz", - "integrity": "sha1-SW2vt3F/X1O3Ue33HZCF4EmEULA=", + "version": "0.73.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-portal-core/-/typespec-azure-portal-core-0.73.0.tgz", + "integrity": "sha512-hlWEPqaEyWaNoNZFt9t7GfGUdVJNhOkHsu2qcpkF8PPhWQ1oDZKLgXsqhCg9h9GSvj0/dz1Mv91dRVMnRLiS1g==", "dev": true, "license": "MIT", "peerDependencies": { - "@azure-tools/typespec-azure-resource-manager": "^0.72.0", - "@typespec/compiler": "^1.16.0" + "@azure-tools/typespec-azure-resource-manager": "^0.73.0", + "@typespec/compiler": "^1.17.0" } }, "node_modules/@azure-tools/typespec-azure-resource-manager": { - "version": "0.72.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.72.1.tgz", - "integrity": "sha512-+idSzZPCOmE5rtkScwbwJ0X48HYYgZ9zcdGvZ5JXgeUKQJJyWbUhiDjjILdGrPxgactwbLiUUz29XJ3tSKBAHw==", + "version": "0.73.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.73.0.tgz", + "integrity": "sha512-Eh9993XDpPgQVmSE9+XXFlBP2eq6mitA7YWr3GwasR3wpdtkbWTXCleMeVl+nlPEbjyEIJmJByeIbApP/043Pg==", "license": "MIT", "dependencies": { "change-case": "^5.4.4", @@ -107,33 +137,33 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.72.1", - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/rest": "^0.86.0", - "@typespec/versioning": "^0.86.0" + "@azure-tools/typespec-azure-core": "^0.73.0", + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/rest": "^0.87.0", + "@typespec/versioning": "^0.87.0" } }, "node_modules/@azure-tools/typespec-azure-rulesets": { - "version": "0.72.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-rulesets/-/typespec-azure-rulesets-0.72.0.tgz", - "integrity": "sha1-qSmhMOzi26BdFaCMEdTbqbyzqxE=", + "version": "0.73.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-rulesets/-/typespec-azure-rulesets-0.73.0.tgz", + "integrity": "sha512-+Wyc8lKEO0ZH+3Zq+4sDz6Yif7UBDzZQKQuegVpX7oWqTHcjr/oknII8PVRBzLKyM8tkiY/ffslT9lEmUoc7tQ==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.72.0", - "@azure-tools/typespec-azure-resource-manager": "^0.72.0", - "@azure-tools/typespec-client-generator-core": "^0.72.0", - "@typespec/compiler": "^1.16.0" + "@azure-tools/typespec-azure-core": "^0.73.0", + "@azure-tools/typespec-azure-resource-manager": "^0.73.0", + "@azure-tools/typespec-client-generator-core": "^0.73.0", + "@typespec/compiler": "^1.17.0" } }, "node_modules/@azure-tools/typespec-client-generator-core": { - "version": "0.72.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.72.2.tgz", - "integrity": "sha512-SHElDtBUsGQA87D27davSwFqMX8q0XkNqKpzKiLF6XzqMaeaDs/wSNlg/KhQR2tEjY1hmTwdGBl7AwhWbaVdow==", + "version": "0.73.1", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.73.1.tgz", + "integrity": "sha512-3HybxFUoKcF5Y2C1zCSO7i2I6gbFe9SZmDbqSSA6oV2pdCDm6jikEQdRbBnyM70U6ZXY0TFPX5kcV266QDvnfQ==", "license": "MIT", "dependencies": { "change-case": "^5.4.4", @@ -144,16 +174,16 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.72.1", - "@typespec/compiler": "^1.16.0", - "@typespec/events": "^0.86.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/rest": "^0.86.0", - "@typespec/sse": "^0.86.0", - "@typespec/streams": "^0.86.0", - "@typespec/versioning": "^0.86.0", - "@typespec/xml": "^0.86.0" + "@azure-tools/typespec-azure-core": "^0.73.0", + "@typespec/compiler": "^1.17.0", + "@typespec/events": "^0.87.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/rest": "^0.87.0", + "@typespec/sse": "^0.87.0", + "@typespec/streams": "^0.87.0", + "@typespec/versioning": "^0.87.0", + "@typespec/xml": "^0.87.0" } }, "node_modules/@azure-tools/typespec-liftr-base": { @@ -163,62 +193,61 @@ "dev": true }, "node_modules/@azure-tools/typespec-python": { - "version": "0.63.8", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-python/-/typespec-python-0.63.8.tgz", - "integrity": "sha512-lBSUSYpH5Yhxs9+q1sFwzEqO8ym5mw3h20Hs2YFUtPqvI+OhG7vpZ1P9UOLT6V/Un9XijDl87Iu6809DJWBM1g==", + "version": "0.63.11", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-python/-/typespec-python-0.63.11.tgz", + "integrity": "sha512-LTC1nTZt4T4NRtr/DV/pc0JEKAWiMqiLk30fz/PX+jJ+8RNP/YS3r/Q/Fo9qGOSFt76EqVZQPblr19g54eDeVg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@typespec/http-client-python": ">=0.37.3 <1.0.0", + "@typespec/http-client-python": ">=0.40.0 <1.0.0", "semver": "^7.8.5" }, "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-autorest": "^0.72.0", - "@azure-tools/typespec-azure-core": "^0.72.0", - "@azure-tools/typespec-azure-resource-manager": "^0.72.0", - "@azure-tools/typespec-azure-rulesets": "^0.72.0", - "@azure-tools/typespec-client-generator-core": "^0.72.1", - "@typespec/compiler": "^1.16.0", - "@typespec/events": "^0.86.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/rest": "^0.86.0", - "@typespec/sse": "^0.86.0", - "@typespec/streams": "^0.86.0", - "@typespec/versioning": "^0.86.0", - "@typespec/xml": "^0.86.0" + "@azure-tools/typespec-autorest": "^0.73.2", + "@azure-tools/typespec-azure-core": "^0.73.0", + "@azure-tools/typespec-azure-resource-manager": "^0.73.0", + "@azure-tools/typespec-azure-rulesets": "^0.73.0", + "@azure-tools/typespec-client-generator-core": "^0.73.0", + "@typespec/compiler": "^1.17.0", + "@typespec/events": "^0.87.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/rest": "^0.87.0", + "@typespec/sse": "^0.87.0", + "@typespec/streams": "^0.87.0", + "@typespec/versioning": "^0.87.0", + "@typespec/xml": "^0.87.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha1-8vu/6ofESiFZDsUVt3iywm2IZuc=", + "version": "8.0.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha1-vYcITO0MeW7Ea9pJLeboPSnon8I=", + "version": "8.0.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha1-v24QMDvPLnxoaXX6Uvk37Cco2Lw=", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -234,7 +263,7 @@ "node_modules/@esbuild/android-arm": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha1-LYTs5qTiaE2SvibuE9QnV9gxw4E=", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -250,7 +279,7 @@ "node_modules/@esbuild/android-arm64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha1-DGJGvI0sTRcqrC2z+xGQ1yvWVQQ=", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -266,7 +295,7 @@ "node_modules/@esbuild/android-x64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha1-/DjU1jWNjcHPU/CfdYn+Q262SAE=", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -314,7 +343,7 @@ "node_modules/@esbuild/freebsd-arm64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha1-CTuSAOzwsRW6Tl4kinSFycX4vV4=", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -330,7 +359,7 @@ "node_modules/@esbuild/freebsd-x64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha1-C+Irbfkl0hPoQeqHEjr134Cw+vc=", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -346,7 +375,7 @@ "node_modules/@esbuild/linux-arm": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha1-vrEq1yuE9y0oSIzBuO6ffrFB11M=", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -378,7 +407,7 @@ "node_modules/@esbuild/linux-ia32": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha1-uB+dVVKbRcIGpGoTghSxqmh5aWs=", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -394,7 +423,7 @@ "node_modules/@esbuild/linux-loong64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha1-WYZnJBoEyZt27W75QKxQA4xBn5g=", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -410,7 +439,7 @@ "node_modules/@esbuild/linux-mips64el": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha1-HFHrnOqQP1PZe1rzsYQdtw9Vlso=", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -426,7 +455,7 @@ "node_modules/@esbuild/linux-ppc64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha1-Y91h8XzrMagSJ/QT/qyKcbwsUfI=", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -442,7 +471,7 @@ "node_modules/@esbuild/linux-riscv64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha1-N2Owj95c8lqx+suOd1Lt/kX7/Cc=", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -458,7 +487,7 @@ "node_modules/@esbuild/linux-s390x": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha1-GhN/8pOoKQbrMXY4W9fo4OXPt8s=", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -490,7 +519,7 @@ "node_modules/@esbuild/netbsd-arm64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha1-Ilca2VHWK7aszILY0frVyMGsC6E=", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -506,7 +535,7 @@ "node_modules/@esbuild/netbsd-x64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha1-QvzFcpfrCgyj9fxHUpH0waP3wN4=", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -522,7 +551,7 @@ "node_modules/@esbuild/openbsd-arm64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha1-nrMq8QSsPaz07coB9ZZmSqsMc+8=", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -538,7 +567,7 @@ "node_modules/@esbuild/openbsd-x64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha1-/r7SQC1giCJekfIPtM4lIq0KTv0=", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -554,7 +583,7 @@ "node_modules/@esbuild/openharmony-arm64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha1-hWQcPUZkKL+8zqXyHCaDZmP+9c4=", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -570,7 +599,7 @@ "node_modules/@esbuild/sunos-x64": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha1-pzb52JYkgQRfxMPlT1R58iyHD7Q=", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -602,7 +631,7 @@ "node_modules/@esbuild/win32-ia32": { "version": "0.28.2", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha1-xA0optmaEn2mcR8q/XSxHLY7Bqc=", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -632,24 +661,24 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.8", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/ansi/-/ansi-2.0.8.tgz", - "integrity": "sha1-Awjz7XkN+pYPDx9gBF+mfoBN444=", + "version": "2.0.10", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/ansi/-/ansi-2.0.10.tgz", + "integrity": "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, "node_modules/@inquirer/checkbox": { - "version": "5.2.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/checkbox/-/checkbox-5.2.5.tgz", - "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "version": "5.2.8", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/checkbox/-/checkbox-5.2.8.tgz", + "integrity": "sha512-NY/S1EalQhlonqhwwsarrcnkH7s9ByQuvjWYdk1jB2Gb9EPQJjKkhCTWjM3yQ0UpHaLm1XbIL89b+32G8rhO5w==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@inquirer/ansi": "^2.0.10", + "@inquirer/core": "^12.0.6", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -664,13 +693,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.3.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/confirm/-/confirm-6.3.2.tgz", - "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "version": "6.3.5", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/confirm/-/confirm-6.3.5.tgz", + "integrity": "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==", "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -685,14 +714,14 @@ } }, "node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "version": "12.0.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/core/-/core-12.0.6.tgz", + "integrity": "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", + "@inquirer/ansi": "^2.0.10", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -710,36 +739,23 @@ } } }, - "node_modules/@inquirer/editor": { - "version": "5.3.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/editor/-/editor-5.3.3.tgz", - "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "node_modules/@inquirer/figures": { + "version": "2.0.10", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/figures/-/figures-2.0.10.tgz", + "integrity": "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==", "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/external-editor": "^3.0.5", - "@inquirer/type": "4.1.1" - }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } } }, - "node_modules/@inquirer/expand": { - "version": "5.1.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/expand/-/expand-5.1.5.tgz", - "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "node_modules/@inquirer/input": { + "version": "5.1.9", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/input/-/input-5.1.9.tgz", + "integrity": "sha512-2Vd5T7lTxfgJMpAd3aOu6bQpbUnCU7tBgvTQIZkP/7juJzmRmTM2t5XfOCuirFduWLTSJI/WZp32Cq1E01TVeg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -753,14 +769,16 @@ } } }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/external-editor/-/external-editor-3.0.5.tgz", - "integrity": "sha1-4FGYttXch6iZP+rW2bz5LNSGNyY=", + "node_modules/@inquirer/select": { + "version": "5.2.8", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/select/-/select-5.2.8.tgz", + "integrity": "sha512-BklkkAAOpjdCfuQw5fVGcDuhKxA7T1iBpn6b2JUvDDdkIsO3Ftlg+WnWUCVpkUBFagL2o036OFW+xL4lC9wIPw==", "license": "MIT", "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" + "@inquirer/ansi": "^2.0.10", + "@inquirer/core": "^12.0.6", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -774,24 +792,11 @@ } } }, - "node_modules/@inquirer/figures": { - "version": "2.0.9", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/figures/-/figures-2.0.9.tgz", - "integrity": "sha1-jAT9ujp4rw5XwLMSfL0xnOYEscE=", + "node_modules/@inquirer/type": { + "version": "4.2.1", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/type/-/type-4.2.1.tgz", + "integrity": "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==", "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.6", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/input/-/input-5.1.6.tgz", - "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" - }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, @@ -804,283 +809,255 @@ } } }, - "node_modules/@inquirer/number": { - "version": "4.2.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/number/-/number-4.2.3.tgz", - "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", - "license": "MIT", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha1-LVmuOrSzj7QnC/oj0w+OLobH/jI=", + "license": "ISC", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "minipass": "^7.0.4" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@inquirer/password": { - "version": "5.2.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/password/-/password-5.2.2.tgz", - "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "node_modules/@scalar/helpers": { + "version": "0.12.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.12.0.tgz", + "integrity": "sha512-rcX0rFLiWWc0VBr/E+HkatubL0I0ZzxASQkd3032NWEMAEfNd2TiJ9kH46HwbSAOOl696ghxymWegjpjjvxwzA==", + "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" - }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@inquirer/prompts": { - "version": "8.7.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/prompts/-/prompts-8.7.2.tgz", - "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", + "node_modules/@scalar/json-magic": { + "version": "0.13.5", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/json-magic/-/json-magic-0.13.5.tgz", + "integrity": "sha512-w5eENfKAkc4CYiLva3nGUhTaJfyi3szmZ3E7+EHlzahOUnbDmB9rAWyzt8GFCXbwxeTuR+1m7lO1PyV/QAid4A==", + "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.2.5", - "@inquirer/confirm": "^6.3.2", - "@inquirer/editor": "^5.3.3", - "@inquirer/expand": "^5.1.5", - "@inquirer/input": "^5.1.6", - "@inquirer/number": "^4.2.3", - "@inquirer/password": "^5.2.2", - "@inquirer/rawlist": "^5.3.5", - "@inquirer/search": "^4.3.3", - "@inquirer/select": "^5.2.5" + "@scalar/helpers": "0.12.0", + "pathe": "^2.0.3", + "undici": "7.24.4", + "yaml": "^2.9.0" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@inquirer/rawlist": { - "version": "5.3.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/rawlist/-/rawlist-5.3.5.tgz", - "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", + "node_modules/@scalar/json-schema-validator": { + "version": "0.1.17", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/json-schema-validator/-/json-schema-validator-0.1.17.tgz", + "integrity": "sha512-4Kmbvpng0V0kgPyqDWUZ1qstKjtDfz7seUCK1/inHjeQ+EJIcXPhoW47sOXf9yQBPbSjp42Wt8a7MZvizyoCPg==", + "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" + "@scalar/helpers": "0.16.4", + "@scalar/types": "0.26.0", + "ajv": "^8.20.0", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "yaml": "^2.9.1" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" + } + }, + "node_modules/@scalar/json-schema-validator/node_modules/@scalar/helpers": { + "version": "0.16.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.16.4.tgz", + "integrity": "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" } }, - "node_modules/@inquirer/search": { - "version": "4.3.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/search/-/search-4.3.3.tgz", - "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", + "node_modules/@scalar/json-schema-validator/node_modules/@scalar/types": { + "version": "0.26.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/types/-/types-0.26.0.tgz", + "integrity": "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg==", + "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@scalar/helpers": "0.16.4", + "nanoid": "^5.1.16", + "type-fest": "^5.10.0", + "zod": "^4.6.7" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@inquirer/select": { - "version": "5.2.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/select/-/select-5.2.5.tgz", - "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "node_modules/@scalar/openapi-parser": { + "version": "0.29.19", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-parser/-/openapi-parser-0.29.19.tgz", + "integrity": "sha512-ajtjKM9sQQOxEKP/VCtLbM9kQCAa0M4XWKsfadVnPvga1p7t1wPtIW+uDph8sHe+nKyydqwYxlZ8Q6RluxhbXw==", + "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" + "@scalar/helpers": "0.16.4", + "@scalar/json-magic": "0.17.0", + "@scalar/openapi-types": "0.9.8", + "@scalar/openapi-upgrader": "0.4.6", + "@scalar/openapi-validator": "0.1.17", + "@scalar/types": "0.26.0", + "yaml": "^2.9.1" }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@inquirer/type": { - "version": "4.1.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@inquirer/type/-/type-4.1.1.tgz", - "integrity": "sha1-+GXumfOeCVGgJ5+cRa0dJgwM4N0=", + "node_modules/@scalar/openapi-parser/node_modules/@scalar/helpers": { + "version": "0.16.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.16.4.tgz", + "integrity": "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha1-LVmuOrSzj7QnC/oj0w+OLobH/jI=", - "license": "ISC", + "node_modules/@scalar/openapi-parser/node_modules/@scalar/json-magic": { + "version": "0.17.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/json-magic/-/json-magic-0.17.0.tgz", + "integrity": "sha512-YmJAO9NoGCcU/vx7P4Dy9DGv+Vntp+ny3Fh99slHOOh/IZxvbdK5hWeZdzQ3x+ezab9XJNscFoX8H/P6W+H8lA==", + "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^7.0.4" + "@scalar/helpers": "0.16.4", + "pathe": "^2.0.3", + "undici": "8.12.3", + "yaml": "^2.9.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.19.0" } }, - "node_modules/@scalar/helpers": { - "version": "0.12.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.12.0.tgz", - "integrity": "sha512-rcX0rFLiWWc0VBr/E+HkatubL0I0ZzxASQkd3032NWEMAEfNd2TiJ9kH46HwbSAOOl696ghxymWegjpjjvxwzA==", + "node_modules/@scalar/openapi-parser/node_modules/@scalar/types": { + "version": "0.26.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/types/-/types-0.26.0.tgz", + "integrity": "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg==", "dev": true, "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.16.4", + "nanoid": "^5.1.16", + "type-fest": "^5.10.0", + "zod": "^4.6.7" + }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/json-magic": { - "version": "0.13.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/json-magic/-/json-magic-0.13.5.tgz", - "integrity": "sha512-w5eENfKAkc4CYiLva3nGUhTaJfyi3szmZ3E7+EHlzahOUnbDmB9rAWyzt8GFCXbwxeTuR+1m7lO1PyV/QAid4A==", + "node_modules/@scalar/openapi-parser/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.9.8", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-types/-/openapi-types-0.9.8.tgz", + "integrity": "sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w==", "dev": true, "license": "MIT", - "dependencies": { - "@scalar/helpers": "0.12.0", - "pathe": "^2.0.3", - "undici": "7.24.4", - "yaml": "^2.9.0" - }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-parser": { - "version": "0.28.16", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-parser/-/openapi-parser-0.28.16.tgz", - "integrity": "sha1-3cvGT7Y6X6KBfH6USTd8zRn6wLk=", + "node_modules/@scalar/openapi-upgrader": { + "version": "0.4.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-upgrader/-/openapi-upgrader-0.4.6.tgz", + "integrity": "sha512-wkTbowGZAlDA5AhCAnBVft6y3veoE17P+okH/zrwM/BZWBSzPghGpa4BPTCg3+IzRmRP/8ZeaYO7LfxCDAEmcA==", "dev": true, "license": "MIT", "dependencies": { - "@scalar/helpers": "0.11.1", - "@scalar/json-magic": "0.13.2", - "@scalar/openapi-types": "0.9.5", - "@scalar/openapi-upgrader": "0.2.15", - "ajv": "^8.17.1", - "ajv-draft-04": "^1.0.0", - "ajv-formats": "^3.0.1", - "jsonpointer": "^5.0.1", - "leven": "^4.0.0", - "yaml": "^2.9.0" + "@scalar/helpers": "0.16.4", + "@scalar/openapi-types": "0.9.8" }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-parser/node_modules/@scalar/helpers": { - "version": "0.11.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.11.1.tgz", - "integrity": "sha1-NapQjFdlPYEokfuwXfUA5YBYbmY=", + "node_modules/@scalar/openapi-upgrader/node_modules/@scalar/helpers": { + "version": "0.16.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.16.4.tgz", + "integrity": "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw==", "dev": true, "license": "MIT", "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-parser/node_modules/@scalar/json-magic": { - "version": "0.13.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/json-magic/-/json-magic-0.13.2.tgz", - "integrity": "sha1-BK3pvF7rqpTtjulTC7mYFbnhvlM=", + "node_modules/@scalar/openapi-validator": { + "version": "0.1.17", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-validator/-/openapi-validator-0.1.17.tgz", + "integrity": "sha512-vBZZgD5k5mp5ii3kNdAti3bZZO0kEvUWYbr9E+Dkjdb67LAdgD5y4Ym0pNIik7prN6KUuUVUu1UMFGFvu1Udqw==", "dev": true, "license": "MIT", "dependencies": { - "@scalar/helpers": "0.11.1", - "pathe": "^2.0.3", - "yaml": "^2.9.0" + "@scalar/helpers": "0.16.4", + "@scalar/json-schema-validator": "0.1.17", + "@scalar/types": "0.26.0", + "yaml": "^2.9.1" }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-parser/node_modules/@scalar/openapi-types": { - "version": "0.9.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-types/-/openapi-types-0.9.5.tgz", - "integrity": "sha1-bV/AZ4EwXbcVOVi/BKJhxuLfyAk=", + "node_modules/@scalar/openapi-validator/node_modules/@scalar/helpers": { + "version": "0.16.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.16.4.tgz", + "integrity": "sha512-9eILWzUcAkHEuYMMvijpSIy69t+Vkq+NudLB/7lgARFTWqXfQpIxW1L3VH7ogKyRie5+E4CNZ3PSvlw6IIfLGw==", "dev": true, "license": "MIT", "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-types": { - "version": "0.9.6", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-types/-/openapi-types-0.9.6.tgz", - "integrity": "sha512-hge0dhllV70dwqgUiucmDY5yZLq91BOV/78lWSqQMTY67x+LHcQplJUZDomfFi5oepROpWDs5iaKMr7ubqDBOA==", + "node_modules/@scalar/openapi-validator/node_modules/@scalar/types": { + "version": "0.26.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/types/-/types-0.26.0.tgz", + "integrity": "sha512-T78Dj1Q+W0E1RsMyPoydfR+L+p9IUIkntBCRYlWaRibM98jAxABRYGQoU/qDHX0PR1IAFdE+d7ZqslypZbJzcg==", "dev": true, "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.16.4", + "nanoid": "^5.1.16", + "type-fest": "^5.10.0", + "zod": "^4.6.7" + }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-upgrader": { - "version": "0.2.15", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.15.tgz", - "integrity": "sha1-NWl7A6N3IwqWHILZloitLxWC/EQ=", + "node_modules/@scalar/types": { + "version": "0.19.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/types/-/types-0.19.0.tgz", + "integrity": "sha1-/Yy3t6BL2p5EbdndkkGRPLiN7zw=", "dev": true, "license": "MIT", "dependencies": { - "@scalar/openapi-types": "0.9.5" + "@scalar/helpers": "0.11.3", + "nanoid": "^5.1.6", + "type-fest": "^5.8.0", + "zod": "^4.4.3" }, "engines": { "node": ">=22" } }, - "node_modules/@scalar/openapi-upgrader/node_modules/@scalar/openapi-types": { - "version": "0.9.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/openapi-types/-/openapi-types-0.9.5.tgz", - "integrity": "sha1-bV/AZ4EwXbcVOVi/BKJhxuLfyAk=", + "node_modules/@scalar/types/node_modules/@scalar/helpers": { + "version": "0.11.3", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@scalar/helpers/-/helpers-0.11.3.tgz", + "integrity": "sha1-aBEImCvT1zS2O821AX0B+3g8MFI=", "dev": true, "license": "MIT", "engines": { @@ -1088,26 +1065,29 @@ } }, "node_modules/@typespec/asset-emitter": { - "version": "0.79.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/asset-emitter/-/asset-emitter-0.79.2.tgz", - "integrity": "sha1-LfhgfN+MQXkuw3aTu/TlZvl7o/E=", + "version": "0.79.3", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/asset-emitter/-/asset-emitter-0.79.3.tgz", + "integrity": "sha512-mqEcgz4wSLe+Y8epvOTJnoOar/ePLSA/xirIaCSPVUvdbZo+fKvPfnKNiPvwtSuhEYlDPewva3PLTPtOTe6GXA==", "dev": true, "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0" + "@typespec/compiler": "^1.17.0" } }, "node_modules/@typespec/compiler": { - "version": "1.16.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/compiler/-/compiler-1.16.0.tgz", - "integrity": "sha1-CoFHbX/Cq9deanySjG4Rpa4xOjA=", + "version": "1.17.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/compiler/-/compiler-1.17.0.tgz", + "integrity": "sha512-Prj9o2bjoWNS9wJ6sUk532jGm8h2AtpQ/lCFH6dhcywiP7UTj9cJtNlDAHptAS9Irh3YJM2g5D4/7S/u/vcG8Q==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@inquirer/prompts": "^8.6.0", + "@babel/code-frame": "^8.0.0", + "@inquirer/checkbox": "^5.2.2", + "@inquirer/confirm": "^6.2.0", + "@inquirer/input": "^5.1.3", + "@inquirer/select": "^5.2.2", "ajv": "^8.20.0", "change-case": "^5.4.4", "env-paths": "^4.0.0", @@ -1132,28 +1112,28 @@ } }, "node_modules/@typespec/events": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/events/-/events-0.86.0.tgz", - "integrity": "sha1-RTSpL9J0s+1Et4rYVC/3NMLCpSM=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/events/-/events-0.87.0.tgz", + "integrity": "sha512-LZHZHnrqOZrQT9bokd9InaXpG35XoChHK4lZqAfJtTTx8zq0kzm5WIZ8fnXttUj332mjjgSNVtZyyrrZFzpKQw==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0" + "@typespec/compiler": "^1.17.0" } }, "node_modules/@typespec/http": { - "version": "1.16.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http/-/http-1.16.0.tgz", - "integrity": "sha1-sj5orGOv4jC2qHbKAWBQrdJQG6I=", + "version": "1.17.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http/-/http-1.17.0.tgz", + "integrity": "sha512-XLwL+NvkQ4hWhnIwZms2CNoxCULFt/jMa+QGaKg4hDwPFLvrXsUm7JWPHi1QOcwzGFG8Z1NglqRPzKl2der2ww==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/streams": "^0.86.0" + "@typespec/compiler": "^1.17.0", + "@typespec/streams": "^0.87.0" }, "peerDependenciesMeta": { "@typespec/streams": { @@ -1162,9 +1142,9 @@ } }, "node_modules/@typespec/http-client-python": { - "version": "0.38.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http-client-python/-/http-client-python-0.38.0.tgz", - "integrity": "sha512-k2LYX905YLky2UsPiUqcD2LoWBvLRRlAKOMs2gmtALrsVHChJ7mIte0FwzhjiW1MmdhV8i3xUuDt7dvpGEogeg==", + "version": "0.40.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http-client-python/-/http-client-python-0.40.0.tgz", + "integrity": "sha512-LlTejcEsh5wq5C7K39ts82B9JcgK1eTRWw/QhKEBMZBEAGL8zTq7F8scQbrZkd9uT3M9jg1igidt3pUyR9kpJA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1178,20 +1158,20 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-autorest": ">=0.72.0 <1.0.0", - "@azure-tools/typespec-azure-core": ">=0.72.0 <1.0.0", - "@azure-tools/typespec-azure-resource-manager": ">=0.72.0 <1.0.0", - "@azure-tools/typespec-azure-rulesets": ">=0.72.0 <1.0.0", - "@azure-tools/typespec-client-generator-core": ">=0.72.1 <1.0.0", - "@typespec/compiler": "^1.16.0", - "@typespec/events": ">=0.86.0 <1.0.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/rest": ">=0.86.0 <1.0.0", - "@typespec/sse": ">=0.86.0 <1.0.0", - "@typespec/streams": ">=0.86.0 <1.0.0", - "@typespec/versioning": ">=0.86.0 <1.0.0", - "@typespec/xml": ">=0.86.0 <1.0.0" + "@azure-tools/typespec-autorest": ">=0.73.2 <1.0.0", + "@azure-tools/typespec-azure-core": ">=0.73.0 <1.0.0", + "@azure-tools/typespec-azure-resource-manager": ">=0.73.0 <1.0.0", + "@azure-tools/typespec-azure-rulesets": ">=0.73.0 <1.0.0", + "@azure-tools/typespec-client-generator-core": ">=0.73.1 <1.0.0", + "@typespec/compiler": "^1.17.0", + "@typespec/events": ">=0.87.0 <1.0.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/rest": ">=0.87.0 <1.0.0", + "@typespec/sse": ">=0.87.0 <1.0.0", + "@typespec/streams": ">=0.87.0 <1.0.0", + "@typespec/versioning": ">=0.87.0 <1.0.0", + "@typespec/xml": ">=0.87.0 <1.0.0" } }, "node_modules/@typespec/http-client-python/node_modules/semver": { @@ -1207,29 +1187,30 @@ } }, "node_modules/@typespec/openapi": { - "version": "1.16.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/openapi/-/openapi-1.16.0.tgz", - "integrity": "sha1-kkBE9vcxWlGUEcn8Mz6o45GMcKY=", + "version": "1.17.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/openapi/-/openapi-1.17.0.tgz", + "integrity": "sha512-0ScXsaovyFZWJv0nnokGI25vK5nRvJ8KfxiKokyXyQrwzK51eb61P/yZr/cZDGjk/w/qKHz2Vz1OvVC4ztdQdA==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0" + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0" } }, "node_modules/@typespec/openapi3": { - "version": "1.16.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/openapi3/-/openapi3-1.16.0.tgz", - "integrity": "sha1-Y7Ltt7DLWy30AR6QXDZRHU0zEYY=", + "version": "1.17.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/openapi3/-/openapi3-1.17.0.tgz", + "integrity": "sha512-BC5zSemNfyFWIgJOj0TrwVA3KULIvCmlrDhqESMbzmN4h3byhXR/PENxPP4TlPKfu3M1KE44esnxSDQ5Py3WnA==", "dev": true, "license": "MIT", "dependencies": { "@scalar/json-magic": "^0.13.2", - "@scalar/openapi-parser": "^0.28.16", + "@scalar/openapi-parser": "^0.29.1", "@scalar/openapi-types": "^0.9.5", - "@typespec/asset-emitter": "^0.79.2", + "@scalar/types": "^0.19.0", + "@typespec/asset-emitter": "^0.79.3", "yaml": "^2.9.0" }, "bin": { @@ -1239,14 +1220,14 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/events": "^0.86.0", - "@typespec/http": "^1.16.0", - "@typespec/json-schema": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/sse": "^0.86.0", - "@typespec/streams": "^0.86.0", - "@typespec/versioning": "^0.86.0" + "@typespec/compiler": "^1.17.0", + "@typespec/events": "^0.87.0", + "@typespec/http": "^1.17.0", + "@typespec/json-schema": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/sse": "^0.87.0", + "@typespec/streams": "^0.87.0", + "@typespec/versioning": "^0.87.0" }, "peerDependenciesMeta": { "@typespec/events": { @@ -1270,67 +1251,67 @@ } }, "node_modules/@typespec/rest": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/rest/-/rest-0.86.0.tgz", - "integrity": "sha1-CCKtPuq87TAfHn3WXxKOEtG3DhU=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/rest/-/rest-0.87.0.tgz", + "integrity": "sha512-Oi47F6IJWyP/efMXf+OUwJTKRrfPpsXPzFZIxG5x2HpAgTwQ0xAUPkrQ6XoQ4rdmwN3XPm0nV54lw5GKljB9LQ==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0" + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0" } }, "node_modules/@typespec/sse": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/sse/-/sse-0.86.0.tgz", - "integrity": "sha1-WIAR64uImWrPROU3MhQLTm/NSy8=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/sse/-/sse-0.87.0.tgz", + "integrity": "sha512-6bAe4zlL+ZYzPCbAXSurTnZ9Jkze1/FAlK2d0i8L81syDtyDs2xWm8quElLzyVks2LkttsO14vFdN2ek5vUJ+g==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/events": "^0.86.0", - "@typespec/http": "^1.16.0", - "@typespec/streams": "^0.86.0" + "@typespec/compiler": "^1.17.0", + "@typespec/events": "^0.87.0", + "@typespec/http": "^1.17.0", + "@typespec/streams": "^0.87.0" } }, "node_modules/@typespec/streams": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/streams/-/streams-0.86.0.tgz", - "integrity": "sha1-kvP4804VN31phPoALkYXwY9EWB4=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/streams/-/streams-0.87.0.tgz", + "integrity": "sha512-m7QLb6VuJiVNM4xuQZMTxoL5xyseLsgmOhUMpmg5HGtAp8toDWaT2N9fxen9BJ3swqdB4P5huQJSVO2BcFB96A==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0" + "@typespec/compiler": "^1.17.0" } }, "node_modules/@typespec/versioning": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/versioning/-/versioning-0.86.0.tgz", - "integrity": "sha1-gLvu6h31gppZDN7JeE9aKW1HNoU=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/versioning/-/versioning-0.87.0.tgz", + "integrity": "sha512-GPL92BH29XAh10o0stg1LfW6RZyyQnlt8LAQ1i1Bfgnf6W0S32nCwbtVgHy8WX1u/g4sXWq74O3xi6Ng5+i68w==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0" + "@typespec/compiler": "^1.17.0" } }, "node_modules/@typespec/xml": { - "version": "0.86.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/xml/-/xml-0.86.0.tgz", - "integrity": "sha1-sqdN1ND8DHZXOLOPmyjjYhERpA4=", + "version": "0.87.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/xml/-/xml-0.87.0.tgz", + "integrity": "sha512-wjcXyYGUEegtMnpqjZzEThhSjILNWuOhEdAriwok4gfxicXcZVNj0l+F+tQcnVjhua0u5dsZimhnohPF/kNqQQ==", "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.16.0" + "@typespec/compiler": "^1.17.0" } }, "node_modules/ajv": { @@ -1368,7 +1349,6 @@ "version": "3.0.1", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha1-PV3HYryhdnnDwup+kK1rdTIwlXg=", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1383,9 +1363,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha1-JHyOe3ChpDsQzhTAIm/L9Y6IFdU=", + "version": "6.5.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", "license": "MIT", "engines": { "node": ">=12" @@ -1418,12 +1398,6 @@ "integrity": "sha1-DVK1B9j7jyBDQ0MjgdGm17/5egI=", "license": "MIT" }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha1-AF1mTyy9SWGIjS4sMsWmnlnY7sQ=", - "license": "MIT" - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/chownr/-/chownr-3.0.0.tgz", @@ -1566,9 +1540,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -1613,9 +1587,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha1-IWkA+R3xGossGYw+HZPWwDWndrk=", + "version": "1.7.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "license": "MIT", "engines": { "node": ">=18" @@ -1624,22 +1598,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha1-hO4S+WPn3lC8AaE+FgoHizsPQV8=", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/is-safe-filename": { "version": "0.1.1", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/is-safe-filename/-/is-safe-filename-0.1.1.tgz", @@ -1665,9 +1623,9 @@ } }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha1-GSA/tZmR35jjoocFDUZHzerzJJk=", + "version": "10.0.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha1-3/51mbSou3/jCv+NAjUjTf+3mDE=", "license": "MIT" }, "node_modules/js-yaml": { @@ -1698,29 +1656,6 @@ "integrity": "sha1-rnvLNlard6c7pcSb9lTzjmtoYOI=", "license": "MIT" }, - "node_modules/jsonpointer": { - "version": "5.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/jsonpointer/-/jsonpointer-5.0.1.tgz", - "integrity": "sha1-IRDgrwkA/TdGe1kH7NE6eIShtVk=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/leven": { - "version": "4.1.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/leven/-/leven-4.1.0.tgz", - "integrity": "sha1-HjcVDhcR0YuxTjgKXHeZlSNacQ4=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/marked/-/marked-15.0.12.tgz", @@ -1772,6 +1707,25 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha1-/jRcCh+QB8Mvu1wTnhIIv9P0Hvc=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/pathe/-/pathe-2.0.3.tgz", @@ -1795,9 +1749,9 @@ } }, "node_modules/prettier": { - "version": "3.9.7", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/prettier/-/prettier-3.9.7.tgz", - "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "version": "3.9.10", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -1830,12 +1784,6 @@ "node": ">=0.10.0" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/semver/-/semver-7.8.5.tgz", @@ -1861,9 +1809,9 @@ } }, "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha1-cxBRZJPfV1dC/pivb66H2F1e0Kw=", + "version": "8.3.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -1891,6 +1839,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha1-oLWRfChky6VIQUlav6P2sT7c9NY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/tar/-/tar-7.5.22.tgz", @@ -1908,9 +1869,9 @@ } }, "node_modules/temporal-polyfill": { - "version": "1.0.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/temporal-polyfill/-/temporal-polyfill-1.0.5.tgz", - "integrity": "sha512-+H/mmY74i6wXCMcjIhGuSnODcyZnc4eois2myhOyX0UqRAKNXQY5m9iB1en5jQ/n4SXtjLXo1ElBI5bmn8tZwQ==", + "version": "1.0.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/temporal-polyfill/-/temporal-polyfill-1.0.6.tgz", + "integrity": "sha512-tYyEry1HLGUDbm2356W3iYD0OZdo7rlP7h98Zr/EnjqLZQwTAfcBDUvJnJbfA15v/vQad57hJZScG5O0W2oKEA==", "license": "MIT", "dependencies": { "temporal-spec": "1.0.1", @@ -1930,9 +1891,9 @@ "license": "MIT" }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha1-OTuN2BPUniWu17HXy6xpMNpJ7Ag=", + "version": "4.23.15", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -1947,6 +1908,22 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "7.24.4", "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/undici/-/undici-7.24.4.tgz", @@ -1958,46 +1935,46 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "9.0.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", - "integrity": "sha1-CGt2xPlPGwdD3urE4LV8nEpV6B0=", + "version": "9.0.3", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-9.0.3.tgz", + "integrity": "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageserver": { - "version": "10.1.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver/-/vscode-languageserver-10.1.1.tgz", - "integrity": "sha1-MAiY6aiBmPsHToKQouakcelP1IQ=", + "version": "10.1.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver/-/vscode-languageserver-10.1.2.tgz", + "integrity": "sha512-pXPWde3RytdeBVEs6PulOOT5OpeXC5GkWRpcT11f5rNn41jviQsAYPyAqJ/0oVGETQLKPJtYbHNiMPlfLb0NTQ==", "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "3.18.3" + "vscode-languageserver-protocol": "3.18.4" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", - "integrity": "sha1-Y5OggGD7lPVSkkP6O+BWxnY5ghE=", + "version": "3.18.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.4.tgz", + "integrity": "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.2", - "vscode-languageserver-types": "3.18.3" + "vscode-jsonrpc": "9.0.3", + "vscode-languageserver-types": "3.18.4" } }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.14", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", - "integrity": "sha1-dzG4CLoakH41YNXkMXvNFLr4Gc4=", + "version": "1.0.16", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.16.tgz", + "integrity": "sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==", "license": "MIT" }, "node_modules/vscode-languageserver-types": { - "version": "3.18.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", - "integrity": "sha1-6ylsQv1lk3WoKVG5zDPnZyup7+c=", + "version": "3.18.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/vscode-languageserver-types/-/vscode-languageserver-types-3.18.4.tgz", + "integrity": "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==", "license": "MIT" }, "node_modules/wrap-ansi": { @@ -2035,9 +2012,9 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ws/-/ws-8.21.3.tgz", - "integrity": "sha1-ZgtPrdtqPldchuB4EmkZlh9N5Pw=", + "version": "8.22.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2089,9 +2066,9 @@ } }, "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha1-zX6YxwPvUWlbu/Bi7VjyjpQpG1Y=", + "version": "18.2.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -2113,6 +2090,16 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/eng/emitter-package.json b/eng/emitter-package.json index 8166813313c9..44f962cc9caa 100644 --- a/eng/emitter-package.json +++ b/eng/emitter-package.json @@ -1,27 +1,27 @@ { "name": "dist/src/index.js", "dependencies": { - "@azure-tools/typespec-python": "0.63.8" + "@azure-tools/typespec-python": "0.63.11" }, "devDependencies": { - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0", - "@typespec/rest": "~0.86.0", - "@typespec/versioning": "~0.86.0", - "@typespec/openapi": "^1.16.0", - "@typespec/events": "~0.86.0", - "@typespec/sse": "~0.86.0", - "@typespec/streams": "~0.86.0", - "@typespec/xml": "~0.86.0", - "@typespec/openapi3": "^1.16.0", - "@typespec/http-client-python": "0.38.0", - "@azure-tools/typespec-autorest": "~0.72.0", - "@azure-tools/typespec-azure-core": "~0.72.0", - "@azure-tools/typespec-azure-resource-manager": "~0.72.0", - "@azure-tools/typespec-azure-rulesets": "~0.72.0", - "@azure-tools/typespec-client-generator-core": "~0.72.1", - "@azure-tools/typespec-azure-portal-core": "~0.72.0", + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0", + "@typespec/rest": "~0.87.0", + "@typespec/versioning": "~0.87.0", + "@typespec/openapi": "^1.17.0", + "@typespec/events": "~0.87.0", + "@typespec/sse": "~0.87.0", + "@typespec/streams": "~0.87.0", + "@typespec/xml": "~0.87.0", + "@typespec/openapi3": "^1.17.0", + "@typespec/http-client-python": "0.40.0", + "@azure-tools/typespec-autorest": "~0.73.2", + "@azure-tools/typespec-azure-core": "~0.73.0", + "@azure-tools/typespec-azure-resource-manager": "~0.73.0", + "@azure-tools/typespec-azure-rulesets": "~0.73.0", + "@azure-tools/typespec-client-generator-core": "~0.73.1", + "@azure-tools/typespec-azure-portal-core": "~0.73.0", "@azure-tools/typespec-liftr-base": "0.13.0", - "@azure-tools/openai-typespec": "1.28.0" + "@azure-tools/openai-typespec": "1.34.1" } } diff --git a/eng/pipelines/autorest_checks.yml b/eng/pipelines/autorest_checks.yml index 5a445378f63f..90913fea0bff 100644 --- a/eng/pipelines/autorest_checks.yml +++ b/eng/pipelines/autorest_checks.yml @@ -15,7 +15,7 @@ pr: variables: NodeVersion: '18.x' - PythonVersion: '3.10' + PythonVersion: '3.11' auto_rest_clone_url: 'https://github.com/Azure/autorest.python.git' source_path_azure_core: 'sdk/core/azure-core' source_path_azure_mgmt_core: 'sdk/core/azure-mgmt-core' diff --git a/eng/pipelines/pullrequest.yml b/eng/pipelines/pullrequest.yml index 41a3275a0477..012187e2d707 100644 --- a/eng/pipelines/pullrequest.yml +++ b/eng/pipelines/pullrequest.yml @@ -2,6 +2,7 @@ pr: branches: include: - main + - azure-identity-beta - feature/* - hotfix/* - release/* diff --git a/eng/pipelines/templates/jobs/apireview-hub-build-tools-python.yml b/eng/pipelines/templates/jobs/apireview-hub-build-tools-python.yml new file mode 100644 index 000000000000..715a8673359e --- /dev/null +++ b/eng/pipelines/templates/jobs/apireview-hub-build-tools-python.yml @@ -0,0 +1,51 @@ +parameters: + - name: languageRepoResource + type: string + - name: toolsRef + type: string + - name: languageToolsRef + type: string + - name: poolName + type: string + default: 'azsdk-pool' + - name: imageOverride + type: string + default: 'ubuntu-24.04' + +jobs: + - template: /eng/common/pipelines/templates/jobs/apireview-hub-build-tools-base.yml + parameters: + languageRepoResource: ${{ parameters.languageRepoResource }} + toolsRef: ${{ parameters.toolsRef }} + languageToolsRef: ${{ parameters.languageToolsRef }} + poolName: ${{ parameters.poolName }} + imageOverride: ${{ parameters.imageOverride }} + stepList: + - bash: | + set -Eeuo pipefail + + required_files=( + "eng/apiview_reqs.txt" + "eng/common/scripts/Export-APIViewMarkdown.ps1" + "eng/scripts/extract_apiview_metadata.py" + ) + for relative_path in "${required_files[@]}"; do + source_path="$LANGUAGE_TOOLS_SOURCE_DIRECTORY/$relative_path" + if [ ! -f "$source_path" ]; then + echo "Expected Python API review tool file was not found at $source_path" >&2 + exit 1 + fi + install -D "$source_path" "$BIN_OUTPUT_DIRECTORY/$relative_path" + done + + tools_package="$LANGUAGE_TOOLS_SOURCE_DIRECTORY/eng/tools/azure-sdk-tools" + if [ ! -d "$tools_package" ]; then + echo "Expected Python SDK tools package was not found at $tools_package" >&2 + exit 1 + fi + mkdir -p "$BIN_OUTPUT_DIRECTORY/eng/tools/azure-sdk-tools" + cp -R "$tools_package/." "$BIN_OUTPUT_DIRECTORY/eng/tools/azure-sdk-tools/" + displayName: Stage Python API review tools + env: + LANGUAGE_TOOLS_SOURCE_DIRECTORY: $(LanguageToolsSourceDirectory) + BIN_OUTPUT_DIRECTORY: $(BinOutputDirectory) diff --git a/eng/pipelines/templates/jobs/apireview-hub-generate-artifact-python.yml b/eng/pipelines/templates/jobs/apireview-hub-generate-artifact-python.yml new file mode 100644 index 000000000000..9e9a047a9367 --- /dev/null +++ b/eng/pipelines/templates/jobs/apireview-hub-generate-artifact-python.yml @@ -0,0 +1,156 @@ +parameters: + - name: kind + type: string + values: + - base + - target + - name: requestMode + type: string + values: + - create + - update + - name: repositoryOwner + type: string + default: Azure + - name: ref + type: string + - name: packageName + type: string + - name: operationId + type: string + - name: poolName + type: string + default: 'azsdk-pool' + - name: imageOverride + type: string + default: 'ubuntu-24.04' + - name: pythonVersion + type: string + default: '3.12' + +jobs: + - template: /eng/common/pipelines/templates/jobs/apireview-hub-generate-artifact-base.yml + parameters: + ${{ if eq(parameters.kind, 'base') }}: + jobName: GenerateBase + ${{ if eq(parameters.kind, 'target') }}: + jobName: GenerateTarget + kind: ${{ parameters.kind }} + requestMode: ${{ parameters.requestMode }} + language: python + repositoryFullName: ${{ format('{0}/{1}', parameters.repositoryOwner, 'azure-sdk-for-python') }} + ref: ${{ parameters.ref }} + packageName: ${{ parameters.packageName }} + operationId: ${{ parameters.operationId }} + poolName: ${{ parameters.poolName }} + imageOverride: ${{ parameters.imageOverride }} + stepList: + - task: UsePythonVersion@0 + displayName: Use Python ${{ parameters.pythonVersion }} + inputs: + versionSpec: ${{ parameters.pythonVersion }} + + - template: /eng/pipelines/templates/steps/auth-dev-feed.yml + + - bash: | + set -Eeuo pipefail + + requirements_file="$BIN_DIRECTORY/eng/apiview_reqs.txt" + azpysdk_package="$BIN_DIRECTORY/eng/tools/azure-sdk-tools[build]" + if [ ! -f "$requirements_file" ]; then + echo "Expected Python API review requirements file was not found at $requirements_file" >&2 + exit 1 + fi + + python -m pip install --upgrade pip + python -m pip install virtualenv + python -m pip install -r "$requirements_file" \ + --index-url="https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" + python -m pip install "$azpysdk_package" + python -m pip install "setuptools<81" + python -c "import apistub, pkg_resources, sys; print(f'Python: {sys.executable}'); print(f'apistub: {apistub.__file__}')" + displayName: Install Python API review tooling + env: + BIN_DIRECTORY: $(BinDirectory) + PIP_NO_INPUT: '1' + + - bash: | + set -Eeuo pipefail + + find_package_dir() { + local matches + matches=$(find "$SOURCE_DIRECTORY/sdk" -mindepth 2 -maxdepth 2 -type d -name "$PACKAGE_NAME" | sort) + if [ -z "$matches" ]; then + echo "Package '$PACKAGE_NAME' was not found under sdk/*/." >&2 + return 1 + fi + if [ "$(printf '%s\n' "$matches" | wc -l)" -ne 1 ]; then + echo "Package '$PACKAGE_NAME' matched multiple directories:" >&2 + printf '%s\n' "$matches" >&2 + return 1 + fi + printf '%s' "$matches" + } + + package_dir=$(find_package_dir) + package_relative_path=$(realpath --relative-to="$SOURCE_DIRECTORY" "$package_dir") + common_tasks_dir="$SOURCE_DIRECTORY/scripts/devops_tasks" + if [ ! -f "$common_tasks_dir/common_tasks.py" ]; then + echo "Expected Python SDK common_tasks.py was not found at $common_tasks_dir/common_tasks.py" >&2 + exit 1 + fi + + install -D \ + "$BIN_DIRECTORY/eng/common/scripts/Export-APIViewMarkdown.ps1" \ + "$SOURCE_DIRECTORY/eng/common/scripts/Export-APIViewMarkdown.ps1" + install -D \ + "$BIN_DIRECTORY/eng/scripts/extract_apiview_metadata.py" \ + "$SOURCE_DIRECTORY/eng/scripts/extract_apiview_metadata.py" + + if ! (cd "$SOURCE_DIRECTORY" && \ + PYTHONPATH="$common_tasks_dir${PYTHONPATH:+:$PYTHONPATH}" \ + python -m azpysdk.main apistub --dest-dir "$package_dir" "$PACKAGE_NAME"); then + echo "API review generation failed without package dependencies; retrying with --install-deps." + (cd "$SOURCE_DIRECTORY" && \ + PYTHONPATH="$common_tasks_dir${PYTHONPATH:+:$PYTHONPATH}" \ + python -m azpysdk.main apistub --install-deps --dest-dir "$package_dir" "$PACKAGE_NAME") + fi + + api_path="$package_dir/api.md" + metadata_path="$package_dir/api.metadata.yml" + if [ ! -f "$api_path" ] || [ ! -f "$metadata_path" ]; then + echo "Expected api.md and api.metadata.yml were not generated in $package_dir" >&2 + exit 1 + fi + + cp "$api_path" "$ARTIFACT_OUTPUT_DIRECTORY/api.md" + python - "$metadata_path" "$ARTIFACT_OUTPUT_DIRECTORY/api.metadata.yml" "$package_relative_path" <<'PY' + import pathlib + import sys + + source = pathlib.Path(sys.argv[1]) + destination = pathlib.Path(sys.argv[2]) + package_relative_path = sys.argv[3] + metadata = {} + for line in source.read_text(encoding="utf-8-sig").splitlines(): + if ":" in line and not line.lstrip().startswith("#"): + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip() + + metadata["packageRelativePath"] = package_relative_path + for field in ("apiHash", "packageVersion", "packageRelativePath"): + if not metadata.get(field): + raise ValueError(f"api.metadata.yml is missing {field}") + + destination.write_text( + "".join(f"{key}: {metadata[key]}\n" for key in sorted(metadata)), + encoding="utf-8", + ) + PY + displayName: Generate ${{ parameters.kind }} Python API review bundle + env: + SOURCE_DIRECTORY: $(Build.SourcesDirectory) + BIN_DIRECTORY: $(BinDirectory) + ARTIFACT_OUTPUT_DIRECTORY: $(ArtifactOutputDirectory) + PACKAGE_NAME: ${{ parameters.packageName }} + PIP_NO_INPUT: '1' diff --git a/eng/pipelines/templates/jobs/apireview-hub-job-python.yml b/eng/pipelines/templates/jobs/apireview-hub-job-python.yml deleted file mode 100644 index 45636c42849b..000000000000 --- a/eng/pipelines/templates/jobs/apireview-hub-job-python.yml +++ /dev/null @@ -1,79 +0,0 @@ -# Python job wrapper for API Review Hub requests. This file owns Python-specific -# setup and delegates shared job boilerplate to the base API Review Hub job. -# The caller supplies the create/update orchestration steps. -parameters: - - name: poolName - type: string - default: 'azsdk-pool' - - name: imageOverride - type: string - default: 'ubuntu-24.04' - - name: repositoryOwner - type: string - default: 'Azure' - - name: languageRepoResource - type: string - - name: pythonVersion - type: string - default: '3.12' - - name: steps - type: stepList - default: [] - -jobs: -- template: /eng/common/pipelines/templates/jobs/apireview-hub-job-base.yml - parameters: - jobName: CreatePythonApiReviewArtifacts - displayName: 'Create Python API review artifacts' - poolName: ${{ parameters.poolName }} - imageOverride: ${{ parameters.imageOverride }} - languageRepoResource: ${{ parameters.languageRepoResource }} - sourceRepositoryFullName: ${{ format('{0}/azure-sdk-for-python', parameters.repositoryOwner) }} - sourceCheckoutDir: $(Pipeline.Workspace)/apireview/source - variables: - - name: PIP_NO_INPUT - value: '1' - setupSteps: - - task: UsePythonVersion@0 - displayName: 'Use Python ${{ parameters.pythonVersion }}' - inputs: - versionSpec: ${{ parameters.pythonVersion }} - - - template: /eng/pipelines/templates/steps/auth-dev-feed.yml - - # Install Python API review tooling once for the job. The base and target - # generation steps reuse this prepared environment. - - bash: | - set -Eeuo pipefail - - source_repo="$SOURCE_REPO" - tooling_dir="$TOOLING_DIR" - requirements_file="$source_repo/eng/apiview_reqs.txt" - azpysdk_package="$source_repo/eng/tools/azure-sdk-tools[build]" - - if [ ! -f "$requirements_file" ]; then - echo "Expected Python API review requirements file was not found at $requirements_file" >&2 - exit 1 - fi - - mkdir -p "$tooling_dir/eng/common/scripts" "$tooling_dir/eng/scripts" - cp "$source_repo/eng/common/scripts/Export-APIViewMarkdown.ps1" "$tooling_dir/eng/common/scripts/Export-APIViewMarkdown.ps1" - cp "$source_repo/eng/scripts/extract_apiview_metadata.py" "$tooling_dir/eng/scripts/extract_apiview_metadata.py" - - python -m pip install --upgrade pip - python -m pip install virtualenv - - echo "Installing Python API review requirements from $requirements_file" - python -m pip install -r "$requirements_file" --index-url="https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" - - echo "Installing azpysdk tooling from the checked-out language repository" - python -m pip install "$azpysdk_package" - - echo "Installing setuptools runtime dependency for Python SDK common_tasks.py" - python -m pip install "setuptools<81" - python -c "import apistub, pkg_resources, sys; print(f'Python: {sys.executable}'); print(f'apistub: {apistub.__file__}')" - displayName: 'Install Python API review tooling' - env: - SOURCE_REPO: $(LanguageRepo) - TOOLING_DIR: $(ApiReviewToolingDir) - steps: ${{ parameters.steps }} \ No newline at end of file diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index 1e0254e4cbc8..f7cb842d0d7a 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -29,6 +29,12 @@ parameters: - name: TestTimeoutInMinutes type: number default: 60 + - name: BuildTimeoutInMinutes + type: number + default: 90 + - name: SbomGenerationTimeoutInMinutes + type: number + default: 30 - name: InjectedPackages type: string default: '' @@ -61,10 +67,26 @@ parameters: - name: EnvVars type: object default: {} + - name: InstallMsRustToolchain + type: boolean + default: false + - name: MsRustWorkingDirectory + type: string + default: '' + - name: MsRustToolchainFeed + type: string + default: '' + - name: MsRustAdditionalTargets + type: string + default: '' jobs: - job: 'Build_Linux' - timeoutInMinutes: 90 + # Native multi-target builds retain a minimum 240-minute budget. + ${{ if and(parameters.InstallMsRustToolchain, lt(parameters.BuildTimeoutInMinutes, 240)) }}: + timeoutInMinutes: 240 + ${{ else }}: + timeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} pool: name: $(LINUXPOOL) @@ -86,11 +108,19 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} ArtifactSuffix: linux + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} BuildTargetingString: ${{ parameters.BuildTargetingString }} ExcludePaths: ${{parameters.ExcludePaths}} + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MsRustAdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} - job: 'Build_Windows' - timeoutInMinutes: 90 + ${{ if and(parameters.InstallMsRustToolchain, lt(parameters.BuildTimeoutInMinutes, 240)) }}: + timeoutInMinutes: 240 + ${{ else }}: + timeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} pool: name: $(WINDOWSPOOL) @@ -105,11 +135,19 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} ArtifactSuffix: windows + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} BuildTargetingString: ${{ parameters.BuildTargetingString }} ExcludePaths: ${{parameters.ExcludePaths}} + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MsRustAdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} - job: 'Build_MacOS' - timeoutInMinutes: 90 + ${{ if and(parameters.InstallMsRustToolchain, lt(parameters.BuildTimeoutInMinutes, 240)) }}: + timeoutInMinutes: 240 + ${{ else }}: + timeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} pool: name: $(MACPOOL) @@ -131,8 +169,13 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} ArtifactSuffix: mac + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} BuildTargetingString: ${{ parameters.BuildTargetingString }} ExcludePaths: ${{parameters.ExcludePaths}} + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MsRustAdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} - job: 'Build_Extended' displayName: Build Extended @@ -141,7 +184,7 @@ jobs: - 'Build_Windows' - 'Build_MacOS' - timeoutInMinutes: 90 + timeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} pool: name: $(LINUXPOOL) @@ -171,6 +214,7 @@ jobs: BeforePublishSteps: ${{ parameters.BeforePublishSteps }} BuildDocs: false RunApiStubGen: true + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} @@ -181,7 +225,7 @@ jobs: - 'Build_Windows' - 'Build_MacOS' - timeoutInMinutes: 90 + timeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} pool: name: $(LINUXPOOL) @@ -213,6 +257,7 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} RunApiStubGen: false + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} - job: 'Analyze' condition: and(succeeded(), ne(variables['Skip.Analyze'], 'true')) @@ -228,6 +273,12 @@ jobs: os: linux steps: + # APIView compares the PR source with origin/, including their merge base. + - checkout: self + ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + fetchDepth: 0 + fetchTags: false + - task: UsePythonVersion@0 displayName: "Use Python $(PythonVersion)" condition: succeededOrFailed() @@ -317,78 +368,7 @@ jobs: TestPipeline: ${{ parameters.TestPipeline }} Artifacts: ${{ parameters.Artifacts }} TestMarkArgument: ${{ parameters.TestMarkArgument }} - BeforeTestSteps: - # PR jobs use ServiceDirectory=auto, so locate services from the job's package batch. - - pwsh: | - $ErrorActionPreference = 'Stop' - $sdkRoot = Join-Path "$(Build.SourcesDirectory)" "sdk" - $serviceDirectories = @() - if ("${{ parameters.ServiceDirectory }}" -eq "auto" -or "${{ parameters.ServiceDirectory }}" -eq "") { - $targets = "$(TargetingString)".Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } - foreach ($service in Get-ChildItem -Path $sdkRoot -Directory) { - $packages = Get-ChildItem -Path $service.FullName -Directory - foreach ($target in $targets) { - if ($packages | Where-Object { $_.Name -like $target }) { - $serviceDirectories += $service.FullName - break - } - } - } - } - else { - $serviceDirectories = "${{ parameters.ServiceDirectory }}".Split('|') ` - | Where-Object { $_ } ` - | ForEach-Object { Join-Path $sdkRoot $_.Trim() } - } - $found = @() - foreach ($root in $serviceDirectories) { - if (-not (Test-Path $root)) { continue } - $found += Get-ChildItem -Path $root -Filter pyproject.toml -Recurse ` - | Where-Object { (Get-Content $_.FullName -Raw) -match '(?m)^\[tool\.cibuildwheel\]' } - } - if ($found) { - $found | ForEach-Object { Write-Host "Found compiled package: $($_.Directory.Name)" } - Write-Host "##vso[task.setvariable variable=CIBUILDWHEEL_DEV_REQ]true" - } - else { - Write-Host "No [tool.cibuildwheel] packages in targeted services; nothing to configure." - Write-Host "##vso[task.setvariable variable=CIBUILDWHEEL_DEV_REQ]false" - } - displayName: 'Check for compiled dev requirements' - - - pwsh: | - Write-Host "##vso[task.setvariable variable=CIBW_ARCHS]native" - Write-Host "##vso[task.setvariable variable=CIBW_SKIP]pp* *musllinux*" - Write-Host "##vso[task.setvariable variable=CIBW_TEST_SKIP]*" - # Passing the feed URL preserves the package's own cibuildwheel environment (CFLAGS). - Write-Host "##vso[task.setvariable variable=CIBW_ENVIRONMENT_PASS_LINUX]PIP_INDEX_URL" - displayName: 'Configure cibuildwheel for dev requirement builds' - condition: and(succeeded(), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) - - # Populate cibuildwheel's CPython cache from CFS instead of public NuGet. - - task: NuGetAuthenticate@1 - displayName: 'Authenticate to NuGet feed' - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) - - - pwsh: | - $ErrorActionPreference = 'Stop' - $feed = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" - $version = "3.10.11" - $cache = Join-Path "$(Agent.TempDirectory)" "cibw-cache" - $nugetCpython = Join-Path $cache "nuget-cpython" - New-Item -ItemType Directory -Force -Path $nugetCpython | Out-Null - $nuget = (Get-Command nuget).Source - & $nuget install python -Version $version -Source $feed -OutputDirectory $nugetCpython - if ($LASTEXITCODE) { - Write-Error "Failed to download python $version" - exit 1 - } - Write-Host "##vso[task.setvariable variable=CIBW_CACHE_PATH]$cache" - displayName: 'Pre-provision CPython for cibuildwheel' - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) - - - ${{ each step in parameters.BeforeTestSteps }}: - - ${{ step }} + BeforeTestSteps: ${{ parameters.BeforeTestSteps }} AfterTestSteps: ${{ parameters.AfterTestSteps }} BuildTargetingString: ${{ parameters.BuildTargetingString }} TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} diff --git a/eng/pipelines/templates/jobs/live.tests.yml b/eng/pipelines/templates/jobs/live.tests.yml index aa6543424629..cba90953e563 100644 --- a/eng/pipelines/templates/jobs/live.tests.yml +++ b/eng/pipelines/templates/jobs/live.tests.yml @@ -69,6 +69,18 @@ parameters: - name: PersistOidcToken type: boolean default: false + - name: DisableAzureResourceCreation + type: boolean + default: false + - name: InstallMsRustToolchain + type: boolean + default: false + - name: MsRustWorkingDirectory + type: string + default: '' + - name: MsRustToolchainFeed + type: string + default: '' jobs: - job: @@ -125,30 +137,31 @@ jobs: parameters: BuildTargetingString: ${{ parameters.BuildTargetingString }} - - template: /eng/common/TestResources/build-test-resource-config.yml - parameters: - SubscriptionConfiguration: ${{ parameters.CloudConfig.SubscriptionConfiguration }} - SubscriptionConfigurations: ${{ parameters.CloudConfig.SubscriptionConfigurations }} - ${{ if parameters.UseFederatedAuth }}: - SubscriptionConfigurationFilePaths: ${{ parameters.CloudConfig.SubscriptionConfigurationFilePaths }} - EnvVars: - Pool: $(Pool) - ${{ insert }}: ${{ parameters.EnvVars }} - - - ${{ each directory in coalesce(parameters.TestResourceDirectories, split(parameters.ServiceDirectory, '|')) }}: - - template: /eng/common/TestResources/deploy-test-resources.yml + - ${{ if not(parameters.DisableAzureResourceCreation) }}: + - template: /eng/common/TestResources/build-test-resource-config.yml parameters: - Location: ${{ coalesce(parameters.Location, parameters.CloudConfig.Location) }} - ServiceDirectory: '${{ directory }}' - SubscriptionConfiguration: $(SubscriptionConfiguration) - ArmTemplateParameters: $(ArmTemplateParameters) - UseFederatedAuth: ${{ parameters.UseFederatedAuth }} - PersistOidcToken: ${{ parameters.PersistOidcToken }} - ServiceConnection: ${{ parameters.CloudConfig.ServiceConnection }} + SubscriptionConfiguration: ${{ parameters.CloudConfig.SubscriptionConfiguration }} + SubscriptionConfigurations: ${{ parameters.CloudConfig.SubscriptionConfigurations }} + ${{ if parameters.UseFederatedAuth }}: + SubscriptionConfigurationFilePaths: ${{ parameters.CloudConfig.SubscriptionConfigurationFilePaths }} EnvVars: Pool: $(Pool) ${{ insert }}: ${{ parameters.EnvVars }} + - ${{ each directory in coalesce(parameters.TestResourceDirectories, split(parameters.ServiceDirectory, '|')) }}: + - template: /eng/common/TestResources/deploy-test-resources.yml + parameters: + Location: ${{ coalesce(parameters.Location, parameters.CloudConfig.Location) }} + ServiceDirectory: '${{ directory }}' + SubscriptionConfiguration: $(SubscriptionConfiguration) + ArmTemplateParameters: $(ArmTemplateParameters) + UseFederatedAuth: ${{ parameters.UseFederatedAuth }} + PersistOidcToken: ${{ parameters.PersistOidcToken }} + ServiceConnection: ${{ parameters.CloudConfig.ServiceConnection }} + EnvVars: + Pool: $(Pool) + ${{ insert }}: ${{ parameters.EnvVars }} + # This is a heuristic to detect compiled requirements in the service # directory. If compiled requirements are found, install dependencies for # and configure cibuildwheel. @@ -170,6 +183,13 @@ jobs: } displayName: 'Check for compiled dev requirements' + - ${{ if parameters.InstallMsRustToolchain }}: + - template: /eng/pipelines/templates/steps/install-msrust-toolchain.yml + parameters: + ToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + WorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + Condition: and(succeeded(), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) + - pwsh: | Write-Host "##vso[task.setvariable variable=CIBW_ARCHS]native" Write-Host "##vso[task.setvariable variable=CIBW_SKIP]pp* *musllinux*" @@ -177,13 +197,19 @@ jobs: # CIBW_ENVIRONMENT_PASS_LINUX, not CIBW_ENVIRONMENT: the latter would clobber the package's # own [tool.cibuildwheel].environment (CFLAGS). PIP_INDEX_URL is set by the time the test step # runs (use-python-version.yml, then auth-dev-feed.yml inside build-test.yml). - Write-Host "##vso[task.setvariable variable=CIBW_ENVIRONMENT_PASS_LINUX]PIP_INDEX_URL" + $passVariables = @('PIP_INDEX_URL') + if ($env:RUST_ENVIRONMENT_PASS) { + $passVariables += $env:RUST_ENVIRONMENT_PASS.Split(' ') + } + Write-Host "##vso[task.setvariable variable=CIBW_ENVIRONMENT_PASS_LINUX]$($passVariables -join ' ')" displayName: 'Configure cibuildwheel for dev requirement builds' condition: and(succeeded(), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) + env: + ${{ if parameters.InstallMsRustToolchain }}: + RUST_ENVIRONMENT_PASS: MSRUSTUP_ACCESS_TOKEN MSRUSTUP_FEED_URL CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_INDEX CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER - # Windows has no cp310 interpreter on the agent and cibuildwheel fetches one from api.nuget.org, - # which is blocked. Pre-provision it from the Azure DevOps feed into the cibuildwheel cache. - # Mirrors steps/build-package-artifacts.yml, minus pythonarm64 (CIBW_ARCHS=native drops win_arm64). + # Download the Python versions selected by cibuildwheel from the approved feed. + # This test job builds only for the current machine's architecture, not ARM64. - task: NuGetAuthenticate@1 displayName: 'Authenticate to NuGet feed' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) @@ -191,17 +217,9 @@ jobs: - pwsh: | $ErrorActionPreference = 'Stop' $feed = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" - $version = "3.10.11" $cache = Join-Path "$(Agent.TempDirectory)" "cibw-cache" - $nugetCpython = Join-Path $cache "nuget-cpython" - New-Item -ItemType Directory -Force -Path $nugetCpython | Out-Null - $nuget = (Get-Command nuget).Source - & $nuget install python -Version $version -Source $feed -OutputDirectory $nugetCpython - if ($LASTEXITCODE) { - Write-Error "Failed to download python $version" - exit 1 - } Write-Host "##vso[task.setvariable variable=CIBW_CACHE_PATH]$cache" + Write-Host "##vso[task.setvariable variable=AZURESDK_PYTHON_NUGET_FEED]$feed" displayName: 'Pre-provision CPython for cibuildwheel' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true')) @@ -212,6 +230,11 @@ jobs: CoverageArg: $(CoverageArg) EnvVars: AZURE_RUN_MODE: 'Live' #Record, Playback + ${{ if parameters.InstallMsRustToolchain }}: + MSRUSTUP_ACCESS_TOKEN: $(System.AccessToken) + MSRUSTUP_FEED_URL: $(MSRUSTUP_FEED_URL) + CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN: $(CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN) + CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER: $(CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER) ${{ insert }}: ${{ parameters.EnvVars }} PythonVersion: $(PythonVersion) OSVmImage: $(OSVmImage) @@ -224,12 +247,13 @@ jobs: UseFederatedAuth: ${{ parameters.UseFederatedAuth }} ServiceConnection: ${{ parameters.CloudConfig.ServiceConnection }} - - ${{ each directory in coalesce(parameters.TestResourceDirectories, split(parameters.ServiceDirectory, '|')) }}: - - template: /eng/common/TestResources/remove-test-resources.yml - parameters: - ServiceDirectory: '${{ directory }}' - SubscriptionConfiguration: $(SubscriptionConfiguration) - UseFederatedAuth: ${{ parameters.UseFederatedAuth }} - ServiceConnection: ${{ parameters.CloudConfig.ServiceConnection }} + - ${{ if not(parameters.DisableAzureResourceCreation) }}: + - ${{ each directory in coalesce(parameters.TestResourceDirectories, split(parameters.ServiceDirectory, '|')) }}: + - template: /eng/common/TestResources/remove-test-resources.yml + parameters: + ServiceDirectory: '${{ directory }}' + SubscriptionConfiguration: $(SubscriptionConfiguration) + UseFederatedAuth: ${{ parameters.UseFederatedAuth }} + ServiceConnection: ${{ parameters.CloudConfig.ServiceConnection }} - ${{ parameters.PostSteps }} diff --git a/eng/pipelines/templates/jobs/regression.yml b/eng/pipelines/templates/jobs/regression.yml index 1fbb02160174..1a196224e6bd 100644 --- a/eng/pipelines/templates/jobs/regression.yml +++ b/eng/pipelines/templates/jobs/regression.yml @@ -82,9 +82,9 @@ jobs: BuildTargetingString: ${{ parameters.BuildTargetingString }} - task: UsePythonVersion@0 - displayName: 'Use Python 3.10' + displayName: 'Use Python 3.11' inputs: - versionSpec: '3.10' + versionSpec: '3.11' - template: /eng/pipelines/templates/steps/use-venv.yml diff --git a/eng/pipelines/templates/jobs/run-cli-tests.yml b/eng/pipelines/templates/jobs/run-cli-tests.yml index 13eefb53f8e0..0378e8342cd7 100644 --- a/eng/pipelines/templates/jobs/run-cli-tests.yml +++ b/eng/pipelines/templates/jobs/run-cli-tests.yml @@ -19,7 +19,7 @@ parameters: default: [] - name: PythonVersion type: string - default: "3.10" + default: "3.11" - name: TargetModules type: string default: "appservice botservice cloud network azure-cli-core azure-cli-telemetry" diff --git a/eng/pipelines/templates/jobs/smoke.tests.yml b/eng/pipelines/templates/jobs/smoke.tests.yml index 6e8a877d61d2..dbe409a7b9e4 100644 --- a/eng/pipelines/templates/jobs/smoke.tests.yml +++ b/eng/pipelines/templates/jobs/smoke.tests.yml @@ -44,8 +44,8 @@ jobs: condition: and(succeeded(), eq(dependencies.smoke_test_eligibility.outputs['output_eligibility.RunSmokeTests'], true)) strategy: matrix: - Python_310_Linux (AzureCloud): - PythonVersion: '3.10' + Python_311_Linux (AzureCloud): + PythonVersion: '3.11' PythonArchitecture: 'x64' pool: @@ -78,8 +78,8 @@ jobs: condition: and(succeeded(), eq(dependencies.smoke_test_eligibility.outputs['output_eligibility.RunSmokeTests'], true)) strategy: matrix: - Python_310_Windows (AzureCloud): - PythonVersion: '3.10' + Python_311_Windows (AzureCloud): + PythonVersion: '3.11' PythonArchitecture: 'x64' pool: @@ -109,8 +109,8 @@ jobs: condition: and(succeeded(), eq(dependencies.smoke_test_eligibility.outputs['output_eligibility.RunSmokeTests'], true)) strategy: matrix: - Python_310_Mac (AzureCloud): - PythonVersion: '3.10' + Python_311_Mac (AzureCloud): + PythonVersion: '3.11' PythonArchitecture: 'arm64' pool: diff --git a/eng/pipelines/templates/stages/1es-redirect.yml b/eng/pipelines/templates/stages/1es-redirect.yml index 139d1d90bd8a..10787e38c831 100644 --- a/eng/pipelines/templates/stages/1es-redirect.yml +++ b/eng/pipelines/templates/stages/1es-redirect.yml @@ -44,7 +44,7 @@ extends: - 1ES.PT.Tag-refs/tags/canary settings: skipBuildTagsForGitHubPullRequests: true - networkIsolationPolicy: Permissive, CFSClean, AzureStorage + networkIsolationPolicy: Permissive, CFSClean, CFSClean2, AzureStorage ${{ if ne(variables['Build.DefinitionName'], 'python - core') }}: featureFlags: autoBaseline: false diff --git a/eng/pipelines/templates/stages/archetype-python-release.yml b/eng/pipelines/templates/stages/archetype-python-release.yml index b5aef37a1b43..1643f8fb8d33 100644 --- a/eng/pipelines/templates/stages/archetype-python-release.yml +++ b/eng/pipelines/templates/stages/archetype-python-release.yml @@ -3,6 +3,7 @@ parameters: TestPipeline: false ArtifactName: 'not-specified' ServiceDirectory: 'not-specified' + ReleasePlanId: '0' DependsOn: Build DocArtifact: 'documentation' DevFeedName: 'public/azure-sdk-for-python' @@ -79,6 +80,7 @@ stages: ArtifactName: ${{ parameters.ArtifactName }} DependsOn: ${{ parameters.DependsOn }} ServiceDirectory: ${{ parameters.ServiceDirectory }} + ReleasePlanId: ${{ parameters.ReleasePlanId }} TestPipeline: ${{ parameters.TestPipeline }} DocArtifact: ${{ parameters.DocArtifact }} DevFeedName: ${{ parameters.DevFeedName }} diff --git a/eng/pipelines/templates/stages/archetype-sdk-client.yml b/eng/pipelines/templates/stages/archetype-sdk-client.yml index 28b8ded76654..3e015a6334f6 100644 --- a/eng/pipelines/templates/stages/archetype-sdk-client.yml +++ b/eng/pipelines/templates/stages/archetype-sdk-client.yml @@ -2,6 +2,9 @@ parameters: - name: ServiceDirectory type: string default: not-specified + - name: ReleasePlanId + type: string + default: '0' - name: SkipPrValidation type: boolean default: false @@ -32,6 +35,12 @@ parameters: - name: TestTimeoutInMinutes type: number default: 60 + - name: BuildTimeoutInMinutes + type: number + default: 90 + - name: SbomGenerationTimeoutInMinutes + type: number + default: 30 - name: InjectedPackages type: string default: '' @@ -87,6 +96,22 @@ parameters: - name: EnvVars type: object default: {} + # Rust setup applies only to wheel jobs, not source-installing test or documentation jobs. + - name: InstallMsRustToolchain + type: boolean + default: false + # Package directory containing rust-toolchain.toml. + - name: MsRustWorkingDirectory + type: string + default: '' + # Required when Rust is enabled; no shared toolchain feed is selected by default. + - name: MsRustToolchainFeed + type: string + default: '' + # Space-separated Windows target triples; keep aligned with the package's wheel recipe. + - name: MsRustAdditionalTargets + type: string + default: '' extends: template: /eng/pipelines/templates/stages/1es-redirect.yml @@ -128,10 +153,16 @@ extends: TestMarkArgument: ${{ parameters.TestMarkArgument }} BuildTargetingString: ${{ parameters.BuildTargetingString }} TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} + BuildTimeoutInMinutes: ${{ parameters.BuildTimeoutInMinutes }} + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} InjectedPackages: ${{ parameters.InjectedPackages }} BuildDocs: ${{ parameters.BuildDocs }} DevFeedName: ${{ parameters.DevFeedName }} EnvVars: ${{ parameters.EnvVars }} + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MsRustAdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} MatrixConfigs: - ${{ each config in parameters.MatrixConfigs }}: - ${{ config }} @@ -143,9 +174,9 @@ extends: TestProxy: ${{ parameters.TestProxy }} GenerateApiReviewForManualOnly: ${{ parameters.GenerateApiReviewForManualOnly }} - - ${{ if and(eq(variables['System.TeamProject'], 'internal'), or(in(variables['Build.Reason'], 'Manual', ''), and(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.SourceBranch'], 'refs/heads/main')))) }}: - - job: FilterPackageInfo - displayName: Filter PackageInfo files for package approval + - ${{ if and(gt(length(parameters.Artifacts), 0), eq(variables['System.TeamProject'], 'internal'), or(in(variables['Build.Reason'], 'Manual', ''), and(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.SourceBranch'], 'refs/heads/main')))) }}: + - job: CheckPackageApproval + displayName: Check Package Approval Status dependsOn: - Build_Extended - Analyze @@ -164,39 +195,6 @@ extends: name: $(LINUXPOOL) image: $(LINUXVMIMAGE) os: linux - steps: - - checkout: self - fetchDepth: 1 - fetchTags: false - sparseCheckoutDirectories: eng/scripts - - - download: current - artifact: packages_extended - - - pwsh: | - & '$(Build.SourcesDirectory)/eng/scripts/Filter-PackageInfoForPackageApproval.ps1' ` - -PackageInfoDirectory '$(Pipeline.Workspace)/packages_extended/PackageInfo' ` - -ArtifactsJson $env:ARTIFACTS_JSON ` - -PackageInfoPathPrefix 'packages_extended/PackageInfo' - name: filterPackageInfo - displayName: Filter management plane packages from package approval - env: - ARTIFACTS_JSON: ${{ convertToJson(parameters.Artifacts) }} - - - job: CheckPackageApproval - displayName: Check Package Approval Status - dependsOn: FilterPackageInfo - condition: >- - and( - in(dependencies.FilterPackageInfo.result, 'Succeeded', 'SucceededWithIssues'), - eq(dependencies.FilterPackageInfo.outputs['filterPackageInfo.HasPackageApprovalPackages'], 'True') - ) - variables: - PackageApprovalInfoFiles: $[ dependencies.FilterPackageInfo.outputs['filterPackageInfo.PackageApprovalInfoFiles'] ] - pool: - name: $(LINUXPOOL) - image: $(LINUXVMIMAGE) - os: linux steps: - checkout: self fetchDepth: 1 @@ -208,13 +206,56 @@ extends: - download: current artifact: packages_extended - - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml + - pwsh: | + $artifacts = ConvertFrom-Json -InputObject $env:ARTIFACTS + $packageInfoFiles = @( + foreach ($artifact in $artifacts) { + $path = "packages_extended/PackageInfo/$($artifact.name).json" + if (Test-Path -LiteralPath $path -PathType Leaf) { + $path + } + else { + Write-Host "No package info exists for '$($artifact.name)'; skipping approval for this artifact." + } + } + ) + $hasPackages = $packageInfoFiles.Count -gt 0 + $paths = $packageInfoFiles -join "','" + Write-Host "##vso[task.setvariable variable=HasPackageApprovalPackages]$hasPackages" + Write-Host "##vso[task.setvariable variable=PackageApprovalInfoFiles]$paths" + displayName: Resolve package approval inputs + workingDirectory: $(Pipeline.Workspace) + env: + ARTIFACTS: ${{ convertToJson(parameters.Artifacts) }} - - template: /eng/common/pipelines/templates/steps/get-package-approval-status.yml + - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml parameters: - PackageInfoFiles: - - $(PackageApprovalInfoFiles) - RepoOwner: Azure + Condition: and(succeeded(), eq(variables['HasPackageApprovalPackages'], 'true')) + + # Invoke the shared script directly to allow skipping a runtime-empty selection. + - task: AzureCLI@2 + displayName: Check Package Approval Status + condition: >- + and( + succeeded(), + eq(variables['HasPackageApprovalPackages'], 'true'), + not( + and( + eq(variables['Skip.CheckPackageApproval'], 'true'), + eq(variables['IsRequesterAuthorizedToSkipApiReview'], 'true') + ) + ) + ) + inputs: + azureSubscription: "ADO to ARH Service Connection" + scriptType: pscore + scriptLocation: scriptPath + scriptPath: $(Build.SourcesDirectory)/eng/common/scripts/Get-PackageApprovalStatus.ps1 + arguments: > + -PackageInfoFiles @('$(PackageApprovalInfoFiles)') + -RepoOwner 'Azure' + -AzSdkExePath '$(AZSDK)' + workingDirectory: $(Pipeline.Workspace) variables: - template: /eng/pipelines/templates/variables/globals.yml @@ -225,6 +266,7 @@ extends: parameters: DependsOn: "Build" ServiceDirectory: ${{ parameters.ServiceDirectory }} + ReleasePlanId: ${{ parameters.ReleasePlanId }} Artifacts: ${{ parameters.Artifacts }} ${{ if eq(parameters.ServiceDirectory, 'template') }}: TestPipeline: true diff --git a/eng/pipelines/templates/stages/archetype-sdk-tests.yml b/eng/pipelines/templates/stages/archetype-sdk-tests.yml index 3eeebbd99369..5e2a00691f8b 100644 --- a/eng/pipelines/templates/stages/archetype-sdk-tests.yml +++ b/eng/pipelines/templates/stages/archetype-sdk-tests.yml @@ -73,6 +73,12 @@ parameters: - name: AdditionalMatrixConfigs type: object default: [] + - name: FixedAccountMatrixConfigs + type: object + default: [] + - name: FixedAccountPreSteps + type: object + default: [] - name: MatrixFilters type: object default: [] @@ -100,6 +106,16 @@ parameters: - name: PersistOidcToken type: boolean default: false + # Opt in when test requirements build a Rust extension from source. + - name: InstallMsRustToolchain + type: boolean + default: false + - name: MsRustWorkingDirectory + type: string + default: '' + - name: MsRustToolchainFeed + type: string + default: '' extends: template: /eng/pipelines/templates/stages/1es-redirect.yml @@ -116,63 +132,73 @@ extends: - group: ${{ group }} dependsOn: [] jobs: - - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml - parameters: - SparseCheckoutPaths: - - sdk/${{ parameters.ServiceDirectory }}/**/*.json - JobTemplatePath: /eng/pipelines/templates/jobs/live.tests.yml - OsVmImage: ubuntu-24.04 - Pool: azsdk-pool - AdditionalParameters: - ServiceDirectory: ${{ parameters.ServiceDirectory }} - TestResourceDirectories: ${{ parameters.TestResourceDirectories }} - PreSteps: - - ${{ parameters.PlatformPreSteps }} - - ${{ parameters.PreSteps }} - PostSteps: - - ${{ parameters.PlatformPostSteps }} - - ${{ parameters.PostSteps }} - EnvVars: ${{ parameters.EnvVars }} - MaxParallel: ${{ parameters.MaxParallel }} - BeforeTestSteps: ${{ parameters.BeforeTestSteps }} - AfterTestSteps: ${{ parameters.AfterTestSteps }} - AdditionalTestArgs: ${{ parameters.AdditionalTestArgs }} - BuildTargetingString: ${{ package }} - TestMarkArgument: ${{ parameters.TestMarkArgument }} - InjectedPackages: ${{ parameters.InjectedPackages }} - BuildDocs: ${{ parameters.BuildDocs }} - TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} - TestProxy: ${{ parameters.TestProxy }} - CheckEnv: ${{ parameters.CheckEnv }} - UseFederatedAuth: ${{ parameters.UseFederatedAuth }} - PersistOidcToken: ${{ parameters.PersistOidcToken }} - MatrixConfigs: - # Enumerate platforms and additional platforms based on supported clouds (sparse platform<-->cloud matrix). - - ${{ each config in parameters.MatrixConfigs }}: - - ${{ config }} - - ${{ each config in parameters.AdditionalMatrixConfigs }}: - - ${{ config }} - MatrixFilters: - - ${{ each cloudFilter in cloud.value.MatrixFilters }}: - - ${{ cloudFilter }} - - ${{ parameters.MatrixFilters }} - MatrixReplace: - - ${{ each cloudReplace in cloud.value.MatrixReplace }}: - - ${{ cloudReplace }} - - ${{ parameters.MatrixReplace }} - CloudConfig: - SubscriptionConfiguration: ${{ cloud.value.SubscriptionConfiguration }} - SubscriptionConfigurations: ${{ cloud.value.SubscriptionConfigurations }} - Location: ${{ coalesce(cloud.value.Location, parameters.Location) }} - Cloud: ${{ cloud.key }} - ServiceConnection: ${{ coalesce(cloud.value.ServiceConnection, lower(format('azure-sdk-tests-{0}', cloud.key))) }} - SubscriptionConfigurationFilePaths: ${{ cloud.value.SubscriptionConfigurationFilePaths }} + - ${{ each matrixType in split('provisioned,fixed', ',') }}: + - ${{ if or(eq(matrixType, 'provisioned'), ne(length(parameters.FixedAccountMatrixConfigs), 0)) }}: + - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml + parameters: + ${{ if eq(matrixType, 'fixed') }}: + GenerateJobName: generate_fixed_account_job_matrix + SparseCheckoutPaths: + - sdk/${{ parameters.ServiceDirectory }}/**/*.json + JobTemplatePath: /eng/pipelines/templates/jobs/live.tests.yml + OsVmImage: ubuntu-24.04 + Pool: azsdk-pool + AdditionalParameters: + ServiceDirectory: ${{ parameters.ServiceDirectory }} + TestResourceDirectories: ${{ parameters.TestResourceDirectories }} + DisableAzureResourceCreation: ${{ eq(matrixType, 'fixed') }} + PreSteps: + - ${{ parameters.PlatformPreSteps }} + - ${{ parameters.PreSteps }} + - ${{ if eq(matrixType, 'fixed') }}: + - ${{ parameters.FixedAccountPreSteps }} + PostSteps: + - ${{ parameters.PlatformPostSteps }} + - ${{ parameters.PostSteps }} + EnvVars: ${{ parameters.EnvVars }} + MaxParallel: ${{ parameters.MaxParallel }} + BeforeTestSteps: ${{ parameters.BeforeTestSteps }} + AfterTestSteps: ${{ parameters.AfterTestSteps }} + AdditionalTestArgs: ${{ parameters.AdditionalTestArgs }} + BuildTargetingString: ${{ package }} + TestMarkArgument: ${{ parameters.TestMarkArgument }} + InjectedPackages: ${{ parameters.InjectedPackages }} + BuildDocs: ${{ parameters.BuildDocs }} + TestTimeoutInMinutes: ${{ parameters.TestTimeoutInMinutes }} + TestProxy: ${{ parameters.TestProxy }} + CheckEnv: ${{ parameters.CheckEnv }} + UseFederatedAuth: ${{ and(parameters.UseFederatedAuth, ne(matrixType, 'fixed')) }} + PersistOidcToken: ${{ parameters.PersistOidcToken }} + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MatrixConfigs: + - ${{ if eq(matrixType, 'fixed') }}: + - ${{ parameters.FixedAccountMatrixConfigs }} + - ${{ else }}: + - ${{ parameters.MatrixConfigs }} + - ${{ parameters.AdditionalMatrixConfigs }} + MatrixFilters: + - ${{ each cloudFilter in cloud.value.MatrixFilters }}: + - ${{ cloudFilter }} + - ${{ parameters.MatrixFilters }} + MatrixReplace: + - ${{ each cloudReplace in cloud.value.MatrixReplace }}: + - ${{ cloudReplace }} + - ${{ parameters.MatrixReplace }} + CloudConfig: + SubscriptionConfiguration: ${{ cloud.value.SubscriptionConfiguration }} + SubscriptionConfigurations: ${{ cloud.value.SubscriptionConfigurations }} + Location: ${{ coalesce(cloud.value.Location, parameters.Location) }} + Cloud: ${{ cloud.key }} + ServiceConnection: ${{ coalesce(cloud.value.ServiceConnection, lower(format('azure-sdk-tests-{0}', cloud.key))) }} + SubscriptionConfigurationFilePaths: ${{ cloud.value.SubscriptionConfigurationFilePaths }} - # Analyze-weekly runs only on scheduled runs that start on a weekend (Sat/Sun, UTC). + # The shared template checks whether the scheduled run started on a weekend (UTC). - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: - template: /eng/pipelines/templates/stages/python-analyze-weekly.yml parameters: BuildTargetingString: ${{ package }} ServiceDirectory: ${{ parameters.ServiceDirectory }} JobName: ${{ parameters.JobName }} - Condition: and(succeeded(), in(format('{0:ddd}', pipeline.startTime), 'Sat', 'Sun')) + Condition: succeeded() diff --git a/eng/pipelines/templates/stages/cosmos-sdk-client.yml b/eng/pipelines/templates/stages/cosmos-sdk-client.yml index 44044764e9f7..90ea9cd3de40 100644 --- a/eng/pipelines/templates/stages/cosmos-sdk-client.yml +++ b/eng/pipelines/templates/stages/cosmos-sdk-client.yml @@ -11,6 +11,18 @@ parameters: - name: TestProxy type: boolean default: false +- name: InstallMsRustToolchain + type: boolean + default: false +- name: MsRustWorkingDirectory + type: string + default: '' +- name: MsRustToolchainFeed + type: string + default: '' +- name: MsRustAdditionalTargets + type: string + default: '' extends: template: /eng/pipelines/templates/stages/archetype-sdk-client.yml @@ -18,6 +30,10 @@ extends: # Run only emulator tests in Emulator CI TestMarkArgument: cosmosEmulator ServiceDirectory: cosmos + InstallMsRustToolchain: ${{ parameters.InstallMsRustToolchain }} + MsRustWorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + MsRustToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + MsRustAdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} BeforeTestSteps: - template: /eng/common/pipelines/templates/steps/cosmos-emulator.yml parameters: diff --git a/eng/pipelines/templates/stages/platform-matrix.json b/eng/pipelines/templates/stages/platform-matrix.json index 17d0ea54f584..6f8cea4d99fd 100644 --- a/eng/pipelines/templates/stages/platform-matrix.json +++ b/eng/pipelines/templates/stages/platform-matrix.json @@ -9,7 +9,7 @@ "ubuntu-24.04": { "OSVmImage": "env:LINUXVMIMAGE", "Pool": "env:LINUXPOOL" }, "windows-2022": { "OSVmImage": "env:WINDOWSVMIMAGE", "Pool": "env:WINDOWSPOOL" } }, - "PythonVersion": [ "3.10", "3.12" ], + "PythonVersion": [ "3.11", "3.12" ], "CoverageArg": "--disablecov", "TestSamples": "false" }, @@ -27,10 +27,10 @@ }, { "CoverageConfig": { - "ubuntu2404_310_coverage": { + "ubuntu2404_311_coverage": { "OSVmImage": "env:LINUXVMIMAGE", "Pool": "env:LINUXPOOL", - "PythonVersion": "3.10", + "PythonVersion": "3.11", "CoverageArg": "", "TestSamples": "false" } @@ -57,6 +57,17 @@ "TestSamples": "false" } } + }, + { + "Config": { + "Ubuntu2404_315": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL", + "PythonVersion": "3.15", + "CoverageArg": "--disablecov", + "TestSamples": "false" + } + } } ] } \ No newline at end of file diff --git a/eng/pipelines/templates/stages/python-analyze-weekly-standalone.yml b/eng/pipelines/templates/stages/python-analyze-weekly-standalone.yml index 42b6c09b0e31..025db8c26d2b 100644 --- a/eng/pipelines/templates/stages/python-analyze-weekly-standalone.yml +++ b/eng/pipelines/templates/stages/python-analyze-weekly-standalone.yml @@ -13,10 +13,10 @@ extends: template: /eng/pipelines/templates/stages/1es-redirect.yml parameters: stages: - # Analyze-weekly runs only on scheduled runs that start on a weekend (Sat/Sun, UTC). + # The shared template checks whether the scheduled run started on a weekend (UTC). - template: /eng/pipelines/templates/stages/python-analyze-weekly.yml parameters: ServiceDirectory: ${{ parameters.ServiceDirectory }} BuildTargetingString: ${{ parameters.BuildTargetingString }} JobName: ${{ parameters.JobName }} - Condition: and(succeeded(), eq(variables['Build.Reason'], 'Schedule'), in(format('{0:ddd}', pipeline.startTime), 'Sat', 'Sun')) \ No newline at end of file + Condition: and(succeeded(), eq(variables['Build.Reason'], 'Schedule')) \ No newline at end of file diff --git a/eng/pipelines/templates/stages/python-analyze-weekly.yml b/eng/pipelines/templates/stages/python-analyze-weekly.yml index ed318e3a67db..3cdd6b993cf0 100644 --- a/eng/pipelines/templates/stages/python-analyze-weekly.yml +++ b/eng/pipelines/templates/stages/python-analyze-weekly.yml @@ -26,7 +26,37 @@ stages: dependsOn: [] condition: ${{ parameters.Condition }} jobs: + - job: CheckWeekend + displayName: 'Check weekend eligibility' + pool: + name: $(LINUXPOOL) + image: $(LINUXVMIMAGE) + os: linux + variables: + # Keep the pipeline expression's date format free of literal separators. + PipelineStartTime: $[ format('{0:yyyyMMddHHmmssK}', pipeline.startTime) ] + steps: + - checkout: none + # Azure Pipelines format() does not support day-of-week specifiers. + - pwsh: | + $startTime = [DateTimeOffset]::ParseExact( + $env:PIPELINE_START_TIME, + 'yyyyMMddHHmmssK', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal + ) + $isWeekend = $startTime.UtcDateTime.DayOfWeek -in @( + [DayOfWeek]::Saturday, [DayOfWeek]::Sunday + ) + Write-Host "##vso[task.setvariable variable=IsWeekend;isOutput=true]$isWeekend" + name: eligibility + displayName: 'Check pipeline start day in UTC' + env: + PIPELINE_START_TIME: $(PipelineStartTime) + - job: 'Analyze' + dependsOn: CheckWeekend + condition: and(succeeded(), eq(dependencies.CheckWeekend.outputs['eligibility.IsWeekend'], 'True')) timeoutInMinutes: 90 pool: @@ -36,9 +66,9 @@ stages: steps: - task: UsePythonVersion@0 - displayName: 'Use Python 3.10' + displayName: 'Use Python 3.11' inputs: - versionSpec: '3.10' + versionSpec: '3.11' # Authenticate pip to the configured Azure DevOps feed before installs. - template: /eng/pipelines/templates/steps/auth-dev-feed.yml diff --git a/eng/pipelines/templates/stages/release-artifact.yml b/eng/pipelines/templates/stages/release-artifact.yml index 95542739f81d..615299f062cc 100644 --- a/eng/pipelines/templates/stages/release-artifact.yml +++ b/eng/pipelines/templates/stages/release-artifact.yml @@ -27,6 +27,9 @@ parameters: - name: ServiceDirectory type: string default: 'not-specified' + - name: ReleasePlanId + type: string + default: '0' - name: TestPipeline type: boolean default: false @@ -266,6 +269,7 @@ stages: parameters: ConfigFileDir: '$(Pipeline.Workspace)/${{parameters.ArtifactName}}/PackageInfo' PackageArtifactName: ${{parameters.Artifact.name}} + ReleasePlanId: ${{ parameters.ReleasePlanId }} # Management-plane-only workaround: skip marking packages that do not generate an APIView revision. - pwsh: | diff --git a/eng/pipelines/templates/steps/build-extended-artifacts.yml b/eng/pipelines/templates/steps/build-extended-artifacts.yml index ce90d0d4cb34..9ab55bd27bde 100644 --- a/eng/pipelines/templates/steps/build-extended-artifacts.yml +++ b/eng/pipelines/templates/steps/build-extended-artifacts.yml @@ -20,6 +20,9 @@ parameters: - name: RunApiStubGen type: boolean default: true + - name: SbomGenerationTimeoutInMinutes + type: number + default: 30 # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: @@ -128,6 +131,7 @@ steps: parameters: ArtifactPath: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'packages_extended' + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} - ${{ if eq(parameters.BuildDocs, 'true') }}: - pwsh: | @@ -140,3 +144,4 @@ steps: ArtifactPath: '$(Build.SourcesDirectory)/_docs' CustomCondition: eq(variables['DirectoryExists'], 'True') ArtifactName: 'documentation' + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} diff --git a/eng/pipelines/templates/steps/build-package-artifacts.yml b/eng/pipelines/templates/steps/build-package-artifacts.yml index 982426287a10..01991726b937 100644 --- a/eng/pipelines/templates/steps/build-package-artifacts.yml +++ b/eng/pipelines/templates/steps/build-package-artifacts.yml @@ -17,12 +17,27 @@ parameters: - name: ArtifactSuffix type: string default: 'linux' + - name: SbomGenerationTimeoutInMinutes + type: number + default: 30 - name: BuildTargetingString type: string default: '*' - name: ExcludePaths type: object default: [] + - name: InstallMsRustToolchain + type: boolean + default: false + - name: MsRustWorkingDirectory + type: string + default: '' + - name: MsRustToolchainFeed + type: string + default: '' + - name: MsRustAdditionalTargets + type: string + default: '' steps: - ${{ if endsWith(variables['Build.Repository.Name'], '-pr') }}: @@ -118,9 +133,18 @@ steps: displayName: 'Install QEMU Dependencies' condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - ${{ if parameters.InstallMsRustToolchain }}: + - template: /eng/pipelines/templates/steps/install-msrust-toolchain.yml + parameters: + ToolchainFeed: ${{ parameters.MsRustToolchainFeed }} + WorkingDirectory: ${{ parameters.MsRustWorkingDirectory }} + ${{ if eq(parameters.ArtifactSuffix, 'windows') }}: + AdditionalTargets: ${{ parameters.MsRustAdditionalTargets }} + Condition: and(succeeded(), eq(variables['ENABLE_EXTENSION_BUILD'], 'true')) + # Windows: cibuildwheel fetches CPython from api.nuget.org, which is blocked. - # Pre-provision the interpreters from the Azure DevOps NuGet feed into the - # cibuildwheel cache so it skips the blocked download. + # sdk_build provisions each package's selected interpreters from this feed, + # using the installed cibuildwheel's metadata and cache layout. - task: NuGetAuthenticate@1 displayName: 'Authenticate to NuGet feed' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['ENABLE_EXTENSION_BUILD'], 'true')) @@ -128,42 +152,57 @@ steps: - pwsh: | $ErrorActionPreference = 'Stop' $feed = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" - $version = "3.10.11" $cache = Join-Path "$(Agent.TempDirectory)" "cibw-cache" - $nugetCpython = Join-Path $cache "nuget-cpython" - New-Item -ItemType Directory -Force -Path $nugetCpython | Out-Null - $nuget = (Get-Command nuget).Source - & $nuget install python -Version $version -Source $feed -OutputDirectory $nugetCpython - if ($LASTEXITCODE) { - Write-Error "Failed to download python $version" - exit 1 - } - & $nuget install pythonarm64 -Version $version -Source $feed -OutputDirectory $nugetCpython - if ($LASTEXITCODE) { - Write-Error "Failed to download pythonarm64 $version" - exit 1 - } Write-Host "##vso[task.setvariable variable=CIBW_CACHE_PATH]$cache" + Write-Host "##vso[task.setvariable variable=AZURESDK_PYTHON_NUGET_FEED]$feed" + if ($env:INSTALL_MS_RUST_TOOLCHAIN -eq 'true') { + Write-Host "##vso[task.setvariable variable=AZURESDK_PYTHON_ARM64_LIBS]true" + } displayName: 'Pre-provision CPython for cibuildwheel' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['ENABLE_EXTENSION_BUILD'], 'true')) + env: + INSTALL_MS_RUST_TOOLCHAIN: ${{ format('{0}', parameters.InstallMsRustToolchain) }} # Linux: forward PIP_INDEX_URL into the build container so pip fetches build # dependencies from the Azure DevOps feed instead of PyPI. Using # CIBW_ENVIRONMENT_PASS_LINUX (not CIBW_ENVIRONMENT) preserves the package's # own [tool.cibuildwheel].environment (CFLAGS) setting. + # This replaces, rather than merges with, the package's environment-pass list. + # Include every variable required by its Linux build hooks. - pwsh: | - Write-Host "##vso[task.setvariable variable=CIBW_ENVIRONMENT_PASS_LINUX]PIP_INDEX_URL" - displayName: 'Forward PIP_INDEX_URL to cibuildwheel' + $passVariables = @('PIP_INDEX_URL') + if ($env:RUST_ENVIRONMENT_PASS) { + $passVariables += $env:RUST_ENVIRONMENT_PASS.Split(' ') + } + Write-Host "##vso[task.setvariable variable=CIBW_ENVIRONMENT_PASS_LINUX]$($passVariables -join ' ')" + displayName: 'Forward build environment to cibuildwheel' condition: and(succeeded(), or(eq(variables['ENABLE_EXTENSION_BUILD'], 'true'), eq('${{ parameters.ArtifactSuffix }}', 'linux'))) + env: + ${{ if parameters.InstallMsRustToolchain }}: + RUST_ENVIRONMENT_PASS: MSRUSTUP_ACCESS_TOKEN MSRUSTUP_FEED_URL CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_INDEX CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER - pwsh: | which python - sdk_build -d "$(Build.ArtifactStagingDirectory)" "$(TargetingString)" --inactive --service="${{ parameters.ServiceDirectory }}" + python eng/scripts/run_with_1es_proxy_trust.py sdk_build -d "$(Build.ArtifactStagingDirectory)" "$(TargetingString)" --inactive --service="${{ parameters.ServiceDirectory }}" + exit $LASTEXITCODE displayName: 'Generate Packages' condition: and(succeeded(), or(eq(variables['ENABLE_EXTENSION_BUILD'], 'true'), eq('${{ parameters.ArtifactSuffix }}', 'linux'))) - timeoutInMinutes: 80 + ${{ if parameters.InstallMsRustToolchain }}: + timeoutInMinutes: 210 + ${{ else }}: + timeoutInMinutes: 80 env: CIBW_BUILD_VERBOSITY: 3 + # The platform resolver selects DEBUG to stream native build output. + LOGLEVEL: $(SDK_BUILD_LOGLEVEL) + ${{ if parameters.InstallMsRustToolchain }}: + # Map secret tokens explicitly for container feed access. Package hooks can read them; + # enforce source-trust and credential permissions through external ADO controls. + MSRUSTUP_ACCESS_TOKEN: $(System.AccessToken) + MSRUSTUP_FEED_URL: $(MSRUSTUP_FEED_URL) + # CargoAuthenticate exports the registry token and credential-provider variables. + CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN: $(CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN) + CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER: $(CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_CREDENTIAL_PROVIDER) - pwsh: | which python @@ -195,15 +234,18 @@ steps: parameters: ArtifactPath: '$(Build.ArtifactStagingDirectory)/release_artifact' ArtifactName: 'release_artifact' + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} - ${{ if eq(parameters.ArtifactSuffix, '') }}: - template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml parameters: ArtifactPath: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'packages' + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} - ${{ if ne(parameters.ArtifactSuffix, '') }}: - template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml parameters: ArtifactPath: '$(Build.ArtifactStagingDirectory)' ArtifactName: 'packages_${{ parameters.ArtifactSuffix }}' + SbomGenerationTimeoutInMinutes: ${{ parameters.SbomGenerationTimeoutInMinutes }} diff --git a/eng/pipelines/templates/steps/build-test.yml b/eng/pipelines/templates/steps/build-test.yml index 8099f4f366b8..46a9919f50f9 100644 --- a/eng/pipelines/templates/steps/build-test.yml +++ b/eng/pipelines/templates/steps/build-test.yml @@ -88,7 +88,7 @@ steps: $markArg = "${{ parameters.TestMarkArgument }}" } - python eng/scripts/dispatch_checks.py + python eng/scripts/run_with_1es_proxy_trust.py python eng/scripts/dispatch_checks.py "$(TargetingString)" ${{ parameters.AdditionalTestArgs }} ${{ parameters.CoverageArg }} @@ -111,7 +111,7 @@ steps: $markArg = "${{ parameters.TestMarkArgument }}" } - python eng/scripts/dispatch_checks.py "$(TargetingString)" ` + python eng/scripts/run_with_1es_proxy_trust.py python eng/scripts/dispatch_checks.py "$(TargetingString)" ` ${{ parameters.AdditionalTestArgs }} ` ${{ parameters.CoverageArg }} ` --mark_arg="$markArg" ` @@ -163,7 +163,7 @@ steps: Write-Host (Get-Command python).Source; - python eng/scripts/dispatch_checks.py "$(TargetingString)" ` + python eng/scripts/run_with_1es_proxy_trust.py python eng/scripts/dispatch_checks.py "$(TargetingString)" ` --service="${{ parameters.ServiceDirectory }}" ` --checks="samples"; @@ -172,7 +172,7 @@ steps: - ${{ else }}: - pwsh: | Write-Host (Get-Command python).Source - python eng/scripts/dispatch_checks.py "$(TargetingString)" ` + python eng/scripts/run_with_1es_proxy_trust.py python eng/scripts/dispatch_checks.py "$(TargetingString)" ` --service="${{ parameters.ServiceDirectory }}" ` --checks="samples"; exit $LASTEXITCODE; diff --git a/eng/pipelines/templates/steps/create-apireview-hub-artifacts-python.yml b/eng/pipelines/templates/steps/create-apireview-hub-artifacts-python.yml deleted file mode 100644 index b392a78cf967..000000000000 --- a/eng/pipelines/templates/steps/create-apireview-hub-artifacts-python.yml +++ /dev/null @@ -1,107 +0,0 @@ -# Generate one Python API review artifact bundle for one ref. This template -# supplies Python-specific generation and delegates shared artifact validation -# and metadata writing to the base API Review Hub artifact template. -parameters: - - name: requestMode - type: string - - name: operationId - type: string - - name: packageName - type: string - - name: ref - type: string - - name: kind - type: string - - name: outputDir - type: string - - name: workingDir - type: string -steps: - - template: /eng/common/pipelines/templates/steps/create-apireview-hub-artifacts-base.yml - parameters: - requestMode: ${{ parameters.requestMode }} - operationId: ${{ parameters.operationId }} - packageName: ${{ parameters.packageName }} - ref: ${{ parameters.ref }} - kind: ${{ parameters.kind }} - outputDir: ${{ parameters.outputDir }} - workingDir: ${{ parameters.workingDir }} - sourceDir: $(ApiReviewSourceDir) - language: python - repositoryFullName: $(ApiReviewRepositoryFullName) - generationSteps: - - bash: | - set -Eeuo pipefail - - source_repo="$SOURCE_REPO" - tooling_dir="$TOOLING_DIR" - output_dir="$OUTPUT_DIR" - state_dir="$WORKING_DIR/state" - - mkdir -p "$source_repo" "$output_dir" "$state_dir" - - find_package_dir() { - local repo="$1" - local package_name="$2" - local matches - - matches=$(find "$repo/sdk" -mindepth 2 -maxdepth 2 -type d -name "$package_name" | sort) - if [ -z "$matches" ]; then - echo "Package '$package_name' was not found under sdk/*/." >&2 - return 1 - fi - - if [ "$(printf '%s\n' "$matches" | wc -l)" -ne 1 ]; then - echo "Package '$package_name' matched multiple directories:" >&2 - printf '%s\n' "$matches" >&2 - return 1 - fi - - printf '%s' "$matches" - } - - package_dir=$(find_package_dir "$source_repo" "$PACKAGE_NAME") - package_relative_path=$(realpath --relative-to="$source_repo" "$package_dir") - - echo "Generating $KIND API review artifact from $REF" - common_tasks_dir="$source_repo/scripts/devops_tasks" - if [ ! -f "$common_tasks_dir/common_tasks.py" ]; then - echo "Expected Python SDK common_tasks.py was not found at $common_tasks_dir/common_tasks.py" >&2 - exit 1 - fi - install -D "$tooling_dir/eng/common/scripts/Export-APIViewMarkdown.ps1" "$source_repo/eng/common/scripts/Export-APIViewMarkdown.ps1" - install -D "$tooling_dir/eng/scripts/extract_apiview_metadata.py" "$source_repo/eng/scripts/extract_apiview_metadata.py" - python -c "import apistub, sys; print(f'Python: {sys.executable}'); print(f'apistub: {apistub.__file__}')" - if ! (cd "$source_repo" && PYTHONPATH="$common_tasks_dir${PYTHONPATH:+:$PYTHONPATH}" python -m azpysdk.main apistub --dest-dir "$package_dir" "$PACKAGE_NAME"); then - echo "API review generation failed without package dependencies; retrying with --install-deps." - (cd "$source_repo" && PYTHONPATH="$common_tasks_dir${PYTHONPATH:+:$PYTHONPATH}" python -m azpysdk.main apistub --install-deps --dest-dir "$package_dir" "$PACKAGE_NAME") - fi - - if [ ! -f "$package_dir/api.md" ]; then - echo "Expected api.md was not generated at $package_dir/api.md" >&2 - exit 1 - fi - if [ ! -f "$package_dir/api.metadata.yml" ]; then - echo "Expected api.metadata.yml was not generated at $package_dir/api.metadata.yml" >&2 - exit 1 - fi - - cp "$package_dir/api.md" "$output_dir/api.md" - cp "$package_dir/api.metadata.yml" "$output_dir/api.metadata.yml" - - version=$(sed -n 's/^packageVersion:[[:space:]]*//p' "$package_dir/api.metadata.yml") - if [ -z "$version" ]; then - echo "Expected packageVersion was not found in $package_dir/api.metadata.yml" >&2 - exit 1 - fi - printf '%s' "$package_relative_path" > "$state_dir/package-relative-path.txt" - printf '%s' "$version" > "$state_dir/version.txt" - displayName: 'Generate ${{ parameters.kind }} Python API review bundle' - env: - SOURCE_REPO: $(ApiReviewSourceDir) - TOOLING_DIR: $(ApiReviewToolingDir) - OUTPUT_DIR: ${{ parameters.outputDir }} - WORKING_DIR: ${{ parameters.workingDir }} - PACKAGE_NAME: ${{ parameters.packageName }} - KIND: ${{ parameters.kind }} - REF: ${{ parameters.ref }} diff --git a/eng/pipelines/templates/steps/install-msrust-toolchain.yml b/eng/pipelines/templates/steps/install-msrust-toolchain.yml new file mode 100644 index 000000000000..dbff0541e538 --- /dev/null +++ b/eng/pipelines/templates/steps/install-msrust-toolchain.yml @@ -0,0 +1,98 @@ +parameters: + # Use an organization-local mirror; the job token cannot authenticate directly to DevDiv. + # See https://aka.ms/rustinado. + - name: ToolchainFeed + type: string + default: '' + # Directory containing rust-toolchain.toml, which selects the compiler version. + - name: WorkingDirectory + type: string + default: '' + - name: AdditionalTargets + type: string + default: '' + # Mirror crates.io for network-isolated builds; ~force-auth makes Cargo authenticate upstream requests. + - name: CratesIoFeed + type: string + default: 'sparse+https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-rust-public~force-auth/Cargo/index/' + - name: Condition + type: string + default: succeeded() + +steps: + - pwsh: | + $ErrorActionPreference = 'Stop' + if (-not $env:RUST_TOOLCHAIN_FEED) { + throw 'An organization-local Rust toolchain feed must be configured.' + } + if (-not $env:RUST_WORKING_DIRECTORY) { + throw 'The directory containing rust-toolchain.toml must be configured.' + } + if (-not (Test-Path (Join-Path $env:RUST_WORKING_DIRECTORY 'rust-toolchain.toml') -PathType Leaf)) { + throw "No rust-toolchain.toml found in $env:RUST_WORKING_DIRECTORY" + } + displayName: 'Validate Rust build configuration' + condition: ${{ parameters.Condition }} + env: + RUST_TOOLCHAIN_FEED: ${{ parameters.ToolchainFeed }} + RUST_WORKING_DIRECTORY: ${{ parameters.WorkingDirectory }} + + # Installs the host toolchain; Linux build containers bootstrap their own. + - task: RustInstaller@1 + displayName: 'Install internal Rust toolchain' + condition: ${{ parameters.Condition }} + inputs: + toolchainFeed: ${{ parameters.ToolchainFeed }} + workingDirectory: ${{ parameters.WorkingDirectory }} + ${{ if ne(parameters.AdditionalTargets, '') }}: + additionalTargets: ${{ parameters.AdditionalTargets }} + + # Configure Cargo after RustInstaller defines CARGO_HOME. + - pwsh: | + $ErrorActionPreference = 'Stop' + Write-Host "##vso[task.setvariable variable=MSRUSTUP_FEED_URL]$env:RUST_TOOLCHAIN_FEED" + $cargoHome = $env:CARGO_HOME + if (-not $cargoHome) { + $cargoHome = [System.IO.Path]::Combine($HOME, '.cargo') + } + New-Item -ItemType Directory -Force -Path $cargoHome | Out-Null + $configPath = Join-Path $cargoHome 'config.toml' + if (Test-Path $configPath) { + throw "Refusing to overwrite an existing Cargo configuration: $configPath" + } + + # Keep the registry name aligned with the forwarded CARGO_REGISTRIES_* variables. + # Use the Git CLI so dependency fetches honor the agent's Git configuration. + @" + [registries] + azure-sdk-for-rust-public = { index = "$env:CARGO_FEED_URL" } + + [source.crates-io] + replace-with = "azure-sdk-for-rust-public" + + [net] + git-fetch-with-cli = true + "@ | Set-Content -Path $configPath -Encoding utf8 + + Write-Host "##vso[task.setvariable variable=CargoConfigPath]$configPath" + # Linux containers cannot see this config; the registry environment variable does not replace sources. + # Package hooks must write their own Cargo source replacement. + Write-Host "##vso[task.setvariable variable=CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_INDEX]$env:CARGO_FEED_URL" + displayName: 'Configure Rust build feeds' + condition: ${{ parameters.Condition }} + env: + RUST_TOOLCHAIN_FEED: ${{ parameters.ToolchainFeed }} + CARGO_FEED_URL: ${{ parameters.CratesIoFeed }} + + - task: NuGetAuthenticate@1 + displayName: 'Acquire an Azure Artifacts token' + condition: ${{ parameters.Condition }} + + # CargoAuthenticate reads SYSTEM_ACCESSTOKEN; use the token acquired by NuGetAuthenticate. + - task: CargoAuthenticate@0 + displayName: 'Authenticate cargo to the Azure Artifacts feed' + condition: ${{ parameters.Condition }} + inputs: + configFile: $(CargoConfigPath) + env: + SYSTEM_ACCESSTOKEN: $(VSS_NUGET_ACCESSTOKEN) diff --git a/eng/pipelines/templates/steps/install-uv.yml b/eng/pipelines/templates/steps/install-uv.yml index 9ae9b10c6a2d..d974fb610728 100644 --- a/eng/pipelines/templates/steps/install-uv.yml +++ b/eng/pipelines/templates/steps/install-uv.yml @@ -3,7 +3,7 @@ steps: $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $indexUrl = if ($env:PIP_INDEX_URL) { $env:PIP_INDEX_URL } else { 'https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/' } - python -m pip install uv==0.11.6 --index-url $indexUrl + python -m pip install uv==0.13.4 --index-url $indexUrl # prepend the path to the installed uv to the PATH so that it can be used in subsequent steps without needing to know the exact path or relying # the global python instance being prepended on the PATH diff --git a/eng/pipelines/templates/steps/resolve-build-platforms.yml b/eng/pipelines/templates/steps/resolve-build-platforms.yml index 2de46faf2585..5955fe572274 100644 --- a/eng/pipelines/templates/steps/resolve-build-platforms.yml +++ b/eng/pipelines/templates/steps/resolve-build-platforms.yml @@ -16,6 +16,7 @@ steps: # variable carries only package names and the decision below needs an artifact property. - pwsh: | $binaryPackages = @() + $logLevel = "INFO" foreach ($packageInfoPath in (Get-ChildItem -Recurse -Force "${{ parameters.PackagePropertiesFolder }}/*.json")) { $packageInfo = Get-Content -Raw -Path $packageInfoPath.FullName | ConvertFrom-Json @@ -28,8 +29,12 @@ steps: if ($binaryPackages) { Write-Host "Targeting package(s) with compiled binaries ($($binaryPackages -join ', ')), enabling extension build." Write-Host "##vso[task.setvariable variable=ENABLE_EXTENSION_BUILD]true" + # DEBUG streams long native builds instead of buffering output until a failure. + $logLevel = "DEBUG" } else { Write-Host "No targeted package declares compiled binaries, building on linux only." } + # Always define the variable used by the build step's LOGLEVEL mapping. + Write-Host "##vso[task.setvariable variable=SDK_BUILD_LOGLEVEL]$logLevel" displayName: Check extension package presence diff --git a/eng/pipelines/templates/steps/run_apistub.yml b/eng/pipelines/templates/steps/run_apistub.yml index eb61c18726b2..4fbbc7c04ff7 100644 --- a/eng/pipelines/templates/steps/run_apistub.yml +++ b/eng/pipelines/templates/steps/run_apistub.yml @@ -6,9 +6,9 @@ parameters: # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: - task: UsePythonVersion@0 - displayName: 'Use Python 3.10' + displayName: 'Use Python 3.11' inputs: - versionSpec: '3.10' + versionSpec: '3.11' condition: and(succeededOrFailed(), ne(variables['Skip.ApiStubGen'],'true')) - template: /eng/pipelines/templates/steps/use-venv.yml diff --git a/eng/pipelines/templates/steps/run_bandit.yml b/eng/pipelines/templates/steps/run_bandit.yml index 8212ff3b5da8..d808e4add5a6 100644 --- a/eng/pipelines/templates/steps/run_bandit.yml +++ b/eng/pipelines/templates/steps/run_bandit.yml @@ -6,7 +6,7 @@ parameters: # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Run Bandit' diff --git a/eng/pipelines/templates/steps/run_black.yml b/eng/pipelines/templates/steps/run_black.yml index 39874f6610eb..703b8dcf61a6 100644 --- a/eng/pipelines/templates/steps/run_black.yml +++ b/eng/pipelines/templates/steps/run_black.yml @@ -4,7 +4,7 @@ parameters: AdditionalTestArgs: '' steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Run Black' diff --git a/eng/pipelines/templates/steps/run_mypy.yml b/eng/pipelines/templates/steps/run_mypy.yml index 7d1490a99364..019e45ca4550 100644 --- a/eng/pipelines/templates/steps/run_mypy.yml +++ b/eng/pipelines/templates/steps/run_mypy.yml @@ -6,7 +6,7 @@ parameters: # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Run MyPy' diff --git a/eng/pipelines/templates/steps/run_pylint.yml b/eng/pipelines/templates/steps/run_pylint.yml index 571d6a1b35c9..b208188e930d 100644 --- a/eng/pipelines/templates/steps/run_pylint.yml +++ b/eng/pipelines/templates/steps/run_pylint.yml @@ -5,7 +5,7 @@ parameters: # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Run Pylint' diff --git a/eng/pipelines/templates/steps/run_pyright.yml b/eng/pipelines/templates/steps/run_pyright.yml index 83b233b1049d..41cc461dd215 100644 --- a/eng/pipelines/templates/steps/run_pyright.yml +++ b/eng/pipelines/templates/steps/run_pyright.yml @@ -6,7 +6,7 @@ parameters: # Please use `$(TargetingString)` to refer to the python packages glob string. This variable is set from resolve-package-targeting.yml. steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Run Pyright' diff --git a/eng/pipelines/templates/steps/seed-virtualenv-wheels.yml b/eng/pipelines/templates/steps/seed-virtualenv-wheels.yml index 6aefa40ad4a5..5771c5b515f5 100644 --- a/eng/pipelines/templates/steps/seed-virtualenv-wheels.yml +++ b/eng/pipelines/templates/steps/seed-virtualenv-wheels.yml @@ -1,7 +1,7 @@ parameters: WHEEL_VERSION: '0.37.0' SETUPTOOLS_VERSION: '59.6.0' - PIP_VERSION: '20.3.3' + PIP_VERSION: '26.2.2' steps: - pwsh: | diff --git a/eng/pipelines/templates/steps/smoke-test-steps.yml b/eng/pipelines/templates/steps/smoke-test-steps.yml index 437fae9a617b..8d7cb18e63eb 100644 --- a/eng/pipelines/templates/steps/smoke-test-steps.yml +++ b/eng/pipelines/templates/steps/smoke-test-steps.yml @@ -23,7 +23,7 @@ steps: DevFeedName: ${{ parameters.DevFeedName }} - script: | - python -m pip install pip==20.0.2 + python -m pip install pip==26.2.2 pip --version displayName: pip --version diff --git a/eng/pipelines/templates/steps/update_snippet.yml b/eng/pipelines/templates/steps/update_snippet.yml index d55c442dd0d8..b92adc012a81 100644 --- a/eng/pipelines/templates/steps/update_snippet.yml +++ b/eng/pipelines/templates/steps/update_snippet.yml @@ -3,7 +3,7 @@ parameters: AdditionalTestArgs: '' steps: - # this step should run on python 3.10. + # this step should run on python 3.11. # a previous step should have installed eng/ci_tools.txt to active venv - task: PythonScript@0 displayName: 'Update Snippets' diff --git a/eng/pipelines/templates/variables/globals.yml b/eng/pipelines/templates/variables/globals.yml index 58f4749d6c65..c68a53f2c2ac 100644 --- a/eng/pipelines/templates/variables/globals.yml +++ b/eng/pipelines/templates/variables/globals.yml @@ -1,5 +1,5 @@ variables: - PythonVersion: '3.10' + PythonVersion: '3.11' skipComponentGovernanceDetection: true AzureSDKCondaChannel: https://azuresdkconda.blob.core.windows.net/channel1/ Package.EnableSBOMSigning: true diff --git a/eng/regression_test_tools.txt b/eng/regression_test_tools.txt index 54f86e20e56f..7a4efbd273f4 100644 --- a/eng/regression_test_tools.txt +++ b/eng/regression_test_tools.txt @@ -1,4 +1,4 @@ -pip==20.2 +pip==26.2.2 # requirements leveraged by ci for testing pytest==7.3.1 diff --git a/eng/regression_tools.txt b/eng/regression_tools.txt index 04862f23660b..4d89ea638f41 100644 --- a/eng/regression_tools.txt +++ b/eng/regression_tools.txt @@ -8,7 +8,7 @@ pathlib2==2.3.5 doc-warden==0.7.2 beautifulsoup4==4.9.1 pkginfo==1.5.0.1 -pip==20.3.3 +pip==26.2.2 typing-extensions<=4.6.3 # locking packages defined as deps from azure-sdk-tools diff --git a/eng/scripts/Filter-PackageInfoForPackageApproval.ps1 b/eng/scripts/Filter-PackageInfoForPackageApproval.ps1 deleted file mode 100644 index b9f364297636..000000000000 --- a/eng/scripts/Filter-PackageInfoForPackageApproval.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -[CmdletBinding()] -param ( - [Parameter(Mandatory = $true)] - [string] $PackageInfoDirectory, - - [Parameter(Mandatory = $true)] - [string] $ArtifactsJson, - - [Parameter(Mandatory = $true)] - [string] $PackageInfoPathPrefix -) - -$artifacts = $ArtifactsJson | ConvertFrom-Json -$packageInfoFiles = @() - -foreach ($artifact in $artifacts) { - $packageInfoPath = Join-Path $PackageInfoDirectory "$($artifact.name).json" - if (!(Test-Path -Path $packageInfoPath -PathType Leaf)) { - Write-Host "PackageInfo file was not found; skipping: $packageInfoPath" - continue - } - - $packageInfo = Get-Content -Raw -Path $packageInfoPath | ConvertFrom-Json - if ($packageInfo.SdkType -eq 'mgmt') { - Write-Host "Package approval is not required for management plane SDKs: $($packageInfo.Name)" - continue - } - - $packageInfoFiles += "$PackageInfoPathPrefix/$($artifact.name).json" -} - -$hasPackageApprovalPackages = $packageInfoFiles.Count -gt 0 -Write-Host "##vso[task.setvariable variable=PackageApprovalInfoFiles;isOutput=true]$($packageInfoFiles -join "','")" -Write-Host "##vso[task.setvariable variable=HasPackageApprovalPackages;isOutput=true]$hasPackageApprovalPackages" \ No newline at end of file diff --git a/eng/scripts/Save-Package-Namespaces-Property.ps1 b/eng/scripts/Save-Package-Namespaces-Property.ps1 index a71512ff9e2f..c58a7fa048ce 100644 --- a/eng/scripts/Save-Package-Namespaces-Property.ps1 +++ b/eng/scripts/Save-Package-Namespaces-Property.ps1 @@ -7,7 +7,8 @@ json file. .DESCRIPTION Given an artifact staging directory and the artifacts list, loop through all artifacts that docs that aren't skipping docs creation and add the namespace to the PackageInfo -json file. This is done using the .whl file from the build. +json file. This is done using the .whl files from the build, combining their +namespaces without duplicates. .PARAMETER ArtifactStagingDirectory The root directory of the staged artifacts. The PackageInfo files will be in the @@ -94,14 +95,14 @@ foreach($packageInfoFile in $artifacts) { # From the $packageInfo piece together the path to the javadoc jar file $WhlDir = Join-Path $ArtifactStagingDirectory $packageInfo.Name $WhlName = $packageInfo.Name.Replace("-","_") - $WhlFile = Get-ChildItem -Path $WhlDir -File -Filter "$whlName-$version*.whl" + $WhlFiles = @(Get-ChildItem -Path $WhlDir -File -Filter "$whlName-$version*.whl") - if (!(Test-Path $WhlFile -PathType Leaf)) { + if ($WhlFiles.Count -eq 0) { LogError "Whl file for, $($packageInfo.Name), was not found in $WhlDir. Please ensure that a .whl file is being produced for the library." $foundError = $true continue } - $namespaces = Get-NamespacesFromWhlFile $packageInfo.Name $version -PythonWhlFile $WhlFile + $namespaces = Get-NamespacesFromWhlFile $packageInfo.Name $version -PythonWhlFile $WhlFiles.FullName if ($namespaces.Count -gt 0) { Write-Host "Adding/Updating Namespaces property with the following namespaces:" $namespaces | Write-Host diff --git a/eng/scripts/docs/Docs-ToC.ps1 b/eng/scripts/docs/Docs-ToC.ps1 index 9416afdeca5f..5cf88531c309 100644 --- a/eng/scripts/docs/Docs-ToC.ps1 +++ b/eng/scripts/docs/Docs-ToC.ps1 @@ -22,45 +22,46 @@ function Get-WhlFile { return $true } -# Given a library and version, download the .whl file and unpack it to get the namespaces. -# A temp directory is used for the download and unpack which is cleaned up afterwards. +# Given a library and version, download a wheel or inspect the supplied wheels. +# Each wheel is unpacked in isolation and the primary namespaces are deduplicated. +# Temporary directories are unique to each invocation and cleaned up afterwards. function Get-NamespacesFromWhlFile { param( [Parameter(Mandatory=$true)] [string]$Library, [Parameter(Mandatory=$true)] [string]$Version, [Parameter(Mandatory=$false)] [string]$ExtraIndexUrl = "", - [Parameter(Mandatory=$false)] [string]$PythonWhlFile = $null + [Parameter(Mandatory=$false)] [string[]]$PythonWhlFile = $null ) - $destination = (Join-Path ([System.IO.Path]::GetTempPath()) "$Library$Version") - New-Item $destination -ItemType Directory | Out-Null + $destination = (Join-Path ([System.IO.Path]::GetTempPath()) "$Library$Version-$([Guid]::NewGuid())") + New-Item $destination -ItemType Directory -ErrorAction Stop | Out-Null $namespaces = @() try { if ($PythonWhlFile) { - if (Test-Path $PythonWhlFile -PathType Leaf) { - Write-Host "Copying $PythonWhlFile to $destination" - Copy-Item $PythonWhlFile -Destination $destination - } else { - LogWarning "$PythonWhlFile, does not exist." - } + $whlFiles = $PythonWhlFile } else { $success = Get-WhlFile $Library $Version $destination $ExtraIndexUrl if (-not $success) { LogWarning "Could not download Whl file for $Library $Version" } + $whlFiles = @(Get-ChildItem -Path $destination -File -Filter "*.whl" | + Select-Object -ExpandProperty FullName) } - # Pulling the whl file generates output, make sure it's sent to null so - # it's not returned as part of this function. - # Each library gets its own temporary directory. There should only be one whl - # file in the destination directory - $whlFile = Get-ChildItem -Path $destination -File -Filter "*.whl" | Select-Object -First 1 - # If we can't download the file or the passed in file doesn't exist then the whlFile - # won't exist, there's nothing to process and an empty namesapces list will be returned - if ($whlFile) { - $unpackDir = Join-Path -Path $destination -ChildPath "$Library-$Version" - Expand-Archive -Path $whlFile -DestinationPath $unpackDir + foreach ($whlFile in $whlFiles) { + if (-not (Test-Path -LiteralPath $whlFile -PathType Leaf)) { + LogWarning "$whlFile, does not exist." + continue + } + + $wheelDestination = Join-Path $destination ([Guid]::NewGuid().ToString()) + New-Item $wheelDestination -ItemType Directory -ErrorAction Stop | Out-Null + Write-Host "Copying $whlFile to $wheelDestination" + Copy-Item -LiteralPath $whlFile -Destination $wheelDestination -ErrorAction Stop + $unpackDir = Join-Path -Path $wheelDestination -ChildPath "$Library-$Version" + $wheelCopy = Join-Path $wheelDestination (Split-Path $whlFile -Leaf) + Expand-Archive -LiteralPath $wheelCopy -DestinationPath $unpackDir -ErrorAction Stop # Look for any directory that contains __init__.py with the following exceptions: # 1. *.dist-info directories shouldn't be included in the results. @@ -93,6 +94,7 @@ function Get-NamespacesFromWhlFile { Remove-Item $destination -Recurse -Force } } + $namespaces = @($namespaces | Sort-Object -Unique) # Make sure this always returns an array Write-Output -NoEnumerate $namespaces } diff --git a/eng/scripts/extract_apiview_metadata.py b/eng/scripts/extract_apiview_metadata.py index 8bf8db71de2d..0eee466cecc7 100644 --- a/eng/scripts/extract_apiview_metadata.py +++ b/eng/scripts/extract_apiview_metadata.py @@ -35,10 +35,10 @@ def extract_metadata(api_markdown_path: pathlib.Path, package_version: str) -> D while filtered and not filtered[0].strip(): filtered.pop(0) - normalized_text = "\n".join(line.rstrip() for line in filtered) - metadata["apiMdSha256"] = hashlib.sha256(normalized_text.encode("utf-8")).hexdigest() + api_markdown_bytes = line_ending.join(filtered).encode("utf-8") + metadata["apiHash"] = hashlib.sha256(api_markdown_bytes).hexdigest() - api_markdown_path.write_text(line_ending.join(filtered), encoding="utf-8", newline="") + api_markdown_path.write_bytes(api_markdown_bytes) metadata_path = api_markdown_path.parent / "api.metadata.yml" metadata_text = line_ending.join(f"{key}: {metadata[key]}" for key in sorted(metadata)) + line_ending metadata_path.write_text(metadata_text, encoding="utf-8", newline="") diff --git a/eng/scripts/run_with_1es_proxy_trust.py b/eng/scripts/run_with_1es_proxy_trust.py new file mode 100644 index 000000000000..babcadc3ca2f --- /dev/null +++ b/eng/scripts/run_with_1es_proxy_trust.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Run a pipeline command with the CA supplied by Windows or Linux 1ES network isolation.""" + +import argparse +import os +import shlex +import ssl +import subprocess +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import certifi + + +def proxy_trust_required() -> bool: + return ( + sys.platform in ("win32", "linux") + and os.environ.get("TF_BUILD", "").lower() == "true" + and bool(os.environ.get("1ESNI_CONFIG_PATH")) + and bool(os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")) + ) + + +def configure_linux_containers(environment: dict[str, str], bundle: Path) -> None: + from cibuildwheel.oci_container import OCIContainerEngineConfig + + engine = OCIContainerEngineConfig.from_config_string( + environment.get("CIBW_CONTAINER_ENGINE_LINUX", environment.get("CIBW_CONTAINER_ENGINE", "docker")) + ) + # Mount the bundle at the same path so SSL_CERT_FILE works on the host and in containers. + create_args = list(engine.create_args) + create_args.append(f"--volume={bundle}:{bundle}:ro") + for name in ( + "SSL_CERT_FILE", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "https_proxy", + "http_proxy", + "no_proxy", + ): + if name in environment: + create_args.append(f"--env={name}") + + environment["CIBW_CONTAINER_ENGINE_LINUX"] = ( + f"{engine.name}; create_args: {shlex.join(create_args)}; " + f"disable_host_mount: {str(engine.disable_host_mount).lower()}" + ) + + +def create_proxy_environment(bundle: Path) -> dict[str, str]: + # Require the infrastructure-provided CA handoff; never infer a platform-specific path. + proxy_ca = os.environ.get("NODE_EXTRA_CA_CERTS") + if not proxy_ca: + raise RuntimeError("1ES proxy trust requires the CA path supplied in NODE_EXTRA_CA_CERTS.") + baseline_ca = os.environ.get("SSL_CERT_FILE", certifi.where()) + + print("Preparing pipeline command TLS trust with the 1ES network-isolation proxy CA.", flush=True) + with bundle.open("wb") as combined: + for certificate_file in (baseline_ca, proxy_ca): + ssl.create_default_context(cafile=certificate_file) + combined.write(Path(certificate_file).read_bytes()) + combined.write(b"\n") + ssl.create_default_context(cafile=str(bundle)) + + environment = os.environ.copy() + environment["SSL_CERT_FILE"] = str(bundle) + if sys.platform == "linux": + configure_linux_containers(environment, bundle) + return environment + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", nargs=argparse.REMAINDER, help="Command and arguments to run without a shell.") + args = parser.parse_args() + if not args.command: + parser.error("a command is required") + + # Bare Python lookup can bypass a Windows launcher-based virtual environment. + if args.command[0] in ("python", "python.exe"): + args.command[0] = sys.executable + + if not proxy_trust_required(): + return subprocess.run(args.command, env=None, check=False).returncode + + with TemporaryDirectory(prefix="pipeline-proxy-ca-") as directory: + bundle = Path(directory).resolve() / "ca-bundle.pem" + environment = create_proxy_environment(bundle) + return subprocess.run(args.command, env=environment, check=False).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eng/scripts/save_package_api_hash.py b/eng/scripts/save_package_api_hash.py index 7e2306f651da..24d9885e9430 100644 --- a/eng/scripts/save_package_api_hash.py +++ b/eng/scripts/save_package_api_hash.py @@ -10,9 +10,9 @@ def update_package_info(metadata_path: pathlib.Path, package_info_path: pathlib.Path) -> None: metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8-sig")) - api_hash = metadata.get("apiMdSha256") if isinstance(metadata, dict) else None - if not isinstance(api_hash, str) or not api_hash: - raise ValueError(f"apiMdSha256 was not found in {metadata_path}") + api_hash = metadata.get("apiHash") if isinstance(metadata, dict) else None + if not isinstance(api_hash, str) or not api_hash.strip(): + raise ValueError(f"apiHash was not found in {metadata_path}") package_info = json.loads(package_info_path.read_text(encoding="utf-8-sig")) package_info["ApiHash"] = api_hash diff --git a/eng/scripts/tests/Package-Namespaces.tests.ps1 b/eng/scripts/tests/Package-Namespaces.tests.ps1 new file mode 100644 index 000000000000..06718a65292a --- /dev/null +++ b/eng/scripts/tests/Package-Namespaces.tests.ps1 @@ -0,0 +1,226 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# Run with Pester 5: Invoke-Pester -Path eng\scripts\tests\Package-Namespaces.tests.ps1 + +BeforeAll { + $scriptsDirectory = Split-Path $PSScriptRoot -Parent + . (Join-Path $scriptsDirectory "docs" "Docs-ToC.ps1") + + function LogWarning($Message) { + Write-Warning $Message + } + + function New-TestWheel { + param([string]$Path, [string[]]$Entries) + + $archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($entry in $Entries) { + $archive.CreateEntry($entry) | Out-Null + } + } + finally { + $archive.Dispose() + } + return $Path + } +} + +Describe "Get-NamespacesFromWhlFile" { + BeforeEach { + $library = "namespace-test-$([Guid]::NewGuid().ToString('N'))" + $version = "1.0.0" + $wheelDirectory = New-Item (Join-Path $TestDrive $library) -ItemType Directory + $firstWheel = New-TestWheel (Join-Path $wheelDirectory "test-1.0.0-cp310-win_amd64.whl") @( + "azure/__init__.py", + "azure/_internal/__init__.py", + "test.dist-info/__init__.py", + "azure/alpha/__init__.py", + "azure/alpha/child/__init__.py" + ) + $secondWheel = New-TestWheel (Join-Path $wheelDirectory "test-1.0.0-cp314-manylinux_x86_64.whl") @( + "azure/zeta/__init__.py" + ) + } + + AfterEach { + $remainingDirectories = @(Get-ChildItem ([System.IO.Path]::GetTempPath()) -Directory -Filter "$library$version*") + try { + $remainingDirectories.Count | Should -Be 0 + } + finally { + foreach ($directory in $remainingDirectories) { + Remove-Item -LiteralPath $directory.FullName -Recurse -Force + } + } + } + + It "preserves the single-wheel array and primary-namespace discovery rules" { + $namespaces = Get-NamespacesFromWhlFile $library $version -PythonWhlFile $firstWheel + + ,$namespaces | Should -BeOfType [array] + $namespaces.Count | Should -Be 1 + $namespaces[0] | Should -BeExactly "azure.alpha" + } + + It "combines distinct namespaces without mixing extracted wheel contents" { + $namespaces = Get-NamespacesFromWhlFile $library $version -PythonWhlFile @($firstWheel, $secondWheel) + + $namespaces | Should -Be @("azure.alpha", "azure.zeta") + } + + It "deduplicates namespaces from multiple platform wheels" { + $duplicateWheel = New-TestWheel (Join-Path $wheelDirectory "test-1.0.0-cp311-macosx_arm64.whl") @( + "azure/alpha/__init__.py" + ) + $namespaces = Get-NamespacesFromWhlFile $library $version -PythonWhlFile @($firstWheel, $duplicateWheel, $secondWheel) + + $namespaces | Should -Be @("azure.alpha", "azure.zeta") + } + + It "isolates wheels with identical filenames from different directories" { + $otherDirectory = New-Item (Join-Path $wheelDirectory "other") -ItemType Directory + $sameNameWheel = New-TestWheel (Join-Path $otherDirectory (Split-Path $firstWheel -Leaf)) @( + "azure/zeta/__init__.py" + ) + $namespaces = Get-NamespacesFromWhlFile $library $version -PythonWhlFile @($firstWheel, $sameNameWheel) + + $namespaces | Should -Be @("azure.alpha", "azure.zeta") + } + + It "does not reuse or remove a preexisting package-version directory" { + $oldDestination = Join-Path ([System.IO.Path]::GetTempPath()) "$library$version" + New-Item $oldDestination -ItemType Directory | Out-Null + $sentinel = Join-Path $oldDestination "sentinel.txt" + Set-Content $sentinel "unrelated contents" + try { + Get-NamespacesFromWhlFile $library $version -PythonWhlFile $firstWheel | Should -Be @("azure.alpha") + Get-Content $sentinel | Should -BeExactly "unrelated contents" + } + finally { + Remove-Item -LiteralPath $oldDestination -Recurse -Force + } + } + + It "warns and returns an empty array for a missing wheel" { + Mock LogWarning {} + $missingWheel = Join-Path $wheelDirectory "missing.whl" + $namespaces = Get-NamespacesFromWhlFile $library $version -PythonWhlFile $missingWheel + + ,$namespaces | Should -BeOfType [array] + $namespaces.Count | Should -Be 0 + Should -Invoke LogWarning -Times 1 -Exactly -ParameterFilter { $Message -eq "$missingWheel, does not exist." } + } + + It "preserves the download path and extra index argument" { + $expectedLibrary = $library + $expectedVersion = $version + Mock Get-WhlFile { + param($Library, $Version, $Destination, $ExtraIndexUrl) + Copy-Item $firstWheel -Destination $Destination + return $true + } + $namespaces = Get-NamespacesFromWhlFile $library $version "https://example.invalid/simple" + + $namespaces | Should -Be @("azure.alpha") + Should -Invoke Get-WhlFile -Times 1 -Exactly -ParameterFilter { + $Library -eq $expectedLibrary -and $Version -eq $expectedVersion -and $ExtraIndexUrl -eq "https://example.invalid/simple" + } + } + + It "warns and returns an empty array when the wheel download fails" { + Mock Get-WhlFile { return $false } + Mock LogWarning {} + $namespaces = Get-NamespacesFromWhlFile $library $version + + ,$namespaces | Should -BeOfType [array] + $namespaces.Count | Should -Be 0 + Should -Invoke LogWarning -Times 1 -Exactly -ParameterFilter { + $Message -eq "Could not download Whl file for $library $version" + } + } + + It "surfaces extraction errors and cleans up temporary files" { + $invalidWheel = Join-Path $wheelDirectory "invalid.whl" + Set-Content $invalidWheel "not a zip archive" + + { Get-NamespacesFromWhlFile $library $version -PythonWhlFile @($firstWheel, $invalidWheel) } | Should -Throw + } +} + +Describe "Save-Package-Namespaces-Property.ps1" { + BeforeAll { + Import-Module powershell-yaml -ErrorAction Stop + $fixtureRoot = Join-Path $TestDrive "repo" + $fixtureScripts = Join-Path $fixtureRoot "eng" "scripts" + $fixtureCommon = Join-Path $fixtureRoot "eng" "common" "scripts" + New-Item (Join-Path $fixtureScripts "docs") -ItemType Directory -Force | Out-Null + New-Item (Join-Path $fixtureCommon "Helpers") -ItemType Directory -Force | Out-Null + Copy-Item (Join-Path $scriptsDirectory "Save-Package-Namespaces-Property.ps1") $fixtureScripts + Copy-Item (Join-Path $scriptsDirectory "docs" "Docs-ToC.ps1") (Join-Path $fixtureScripts "docs") + Copy-Item (Join-Path $scriptsDirectory ".." "common" "scripts" "Helpers" "PSModule-Helpers.ps1") (Join-Path $fixtureCommon "Helpers") + # Isolate repository discovery and logging; execute the real caller and extractor. + Set-Content (Join-Path $fixtureCommon "common.ps1") @' +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot ".." ".." "..") +. (Join-Path $RepoRoot "eng" "scripts" "docs" "Docs-ToC.ps1") +function LogError($Message) { Write-Host "ERROR: $Message" } +function LogWarning($Message) { Write-Warning $Message } +'@ + $serviceDirectory = Join-Path $fixtureRoot "sdk" "test" + New-Item $serviceDirectory -ItemType Directory -Force | Out-Null + Set-Content (Join-Path $serviceDirectory "ci.yml") @' +extends: + parameters: + Artifacts: + - name: azure-test +'@ + $saveScript = Join-Path $fixtureScripts "Save-Package-Namespaces-Property.ps1" + $pwsh = (Get-Process -Id $PID).Path + } + + BeforeEach { + $stagingDirectory = Join-Path $TestDrive ([Guid]::NewGuid().ToString()) + $wheelDirectory = Join-Path $stagingDirectory "azure-test" + New-Item $wheelDirectory -ItemType Directory -Force | Out-Null + $packageInfoDirectory = New-Item (Join-Path $stagingDirectory "PackageInfo") -ItemType Directory + $packageInfoFile = Join-Path $packageInfoDirectory "azure-test.json" + @{ + Name = "azure-test" + Version = "1.0.0" + DevVersion = "" + ServiceDirectory = "test" + } | ConvertTo-Json | Set-Content $packageInfoFile + } + + It "writes a deduplicated JSON array from all matching wheels" { + New-TestWheel (Join-Path $wheelDirectory "azure_test-1.0.0-cp310-win_amd64.whl") @("azure/alpha/__init__.py") | Out-Null + New-TestWheel (Join-Path $wheelDirectory "azure_test-1.0.0-cp311-macosx_arm64.whl") @("azure/alpha/__init__.py") | Out-Null + New-TestWheel (Join-Path $wheelDirectory "azure_test-1.0.0-cp314-manylinux_x86_64.whl") @("azure/zeta/__init__.py") | Out-Null + New-TestWheel (Join-Path $wheelDirectory "azure_test-2.0.0-py3-none-any.whl") @("azure/unrelated/__init__.py") | Out-Null + + $output = & $pwsh -NoProfile -File $saveScript -ArtifactStagingDirectory $stagingDirectory 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join [Environment]::NewLine) + $packageInfo = Get-Content $packageInfoFile -Raw | ConvertFrom-Json + ,$packageInfo.Namespaces | Should -BeOfType [array] + $packageInfo.Namespaces | Should -Be @("azure.alpha", "azure.zeta") + } + + It "preserves single-wheel output as a JSON array" { + New-TestWheel (Join-Path $wheelDirectory "azure_test-1.0.0-py3-none-any.whl") @("azure/alpha/__init__.py") | Out-Null + + $output = & $pwsh -NoProfile -File $saveScript -ArtifactStagingDirectory $stagingDirectory 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join [Environment]::NewLine) + $packageInfo = Get-Content $packageInfoFile -Raw | ConvertFrom-Json + ,$packageInfo.Namespaces | Should -BeOfType [array] + $packageInfo.Namespaces | Should -Be @("azure.alpha") + } + + It "reports the missing wheel and exits with failure without modifying metadata" { + $original = Get-Content $packageInfoFile -Raw + $output = & $pwsh -NoProfile -File $saveScript -ArtifactStagingDirectory $stagingDirectory 2>&1 + + $LASTEXITCODE | Should -Be 1 + ($output -join [Environment]::NewLine) | Should -Match "Whl file for, azure-test, was not found" + Get-Content $packageInfoFile -Raw | Should -BeExactly $original + } +} diff --git a/eng/scripts/tests/Release-CompletionCorrelation.tests.ps1 b/eng/scripts/tests/Release-CompletionCorrelation.tests.ps1 new file mode 100644 index 000000000000..805eeaae5200 --- /dev/null +++ b/eng/scripts/tests/Release-CompletionCorrelation.tests.ps1 @@ -0,0 +1,329 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# Parsed YAML contracts for optional release IDs and the shared SDK PR fallback. +# Requires Pester 5 and powershell-yaml; no Python runtime or cloud access. +# Run Invoke-Pester -Path this file. This does not expand Azure Pipelines templates. + +BeforeAll { + Set-StrictMode -Version 4 + $ErrorActionPreference = 'Stop' + Import-Module powershell-yaml -ErrorAction Stop + $root = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path + $paths = @( + 'sdk/template/ci.yml', + 'eng/pipelines/templates/stages/archetype-sdk-client.yml', + 'eng/pipelines/templates/stages/archetype-python-release.yml', + 'eng/pipelines/templates/stages/release-artifact.yml' + ) + $documents = @($paths | ForEach-Object { + ConvertFrom-Yaml (Get-Content -LiteralPath (Join-Path $root $_) -Raw) + }) + $entry, $client, $release, $artifact = $documents + $script:idExpression = '${{ parameters.ReleasePlanId }}' + $script:templateGuard = '${{ if eq(parameters.ServiceDirectory, ''template'') }}' + $script:autoGuard = '${{ if and(eq(variables[''Build.Reason''], ''IndividualCI''), eq(variables[''Build.SourceBranch''], ''refs/heads/main'')) }}' + + function Find-YamlMap { + param([object]$Node, [string]$Key, [string]$Value) + if ($Node -is [System.Collections.IDictionary]) { + if ($Node.Contains($Key) -and $Node[$Key] -eq $Value) { + ,$Node + } + foreach ($child in $Node.Values) { + Find-YamlMap -Node $child -Key $Key -Value $Value + } + } elseif ($Node -is [System.Collections.IList]) { + foreach ($child in $Node) { + Find-YamlMap -Node $child -Key $Key -Value $Value + } + } + } + + $script:clientRelay = @(Find-YamlMap $client template 'archetype-python-release.yml')[0] + $script:artifactRelay = @(Find-YamlMap $release template '/eng/pipelines/templates/stages/release-artifact.yml')[0] + $script:stage = $artifact.stages[0] + $script:completionJob = @(Find-YamlMap $artifact job MarkPackageReleaseCompletion)[0] + $script:completion = @(Find-YamlMap $artifact template '/eng/common/pipelines/templates/steps/mark-release-completion.yml')[0] +} + +Describe 'Python template completion correlation' { + It 'declares optional string IDs without numeric conversion at every boundary' { + foreach ($document in @($entry, $client, $artifact)) { + $parameter = @($document.parameters | Where-Object { $_.name -eq 'ReleasePlanId' }) + $parameter.Count | Should -Be 1 + $parameter[0].type | Should -BeExactly 'string' + $parameter[0].default | Should -BeOfType ([string]) + $parameter[0].default | Should -BeExactly '0' + } + # Preserve this template's existing mapping-style parameter declaration. + $release.parameters.ReleasePlanId | Should -BeOfType ([string]) + $release.parameters.ReleasePlanId | Should -BeExactly '0' + } + + It 'relays the raw ID through entry, client and per-artifact release' { + $entry.extends.parameters.ServiceDirectory | Should -BeExactly 'template' + foreach ($relay in @($entry.extends, $clientRelay, $artifactRelay)) { + $relay.parameters.ReleasePlanId | Should -BeExactly $idExpression + } + } + + It 'explains that the default ID uses package name based lookup' { + $parameter = @($entry.parameters | Where-Object name -eq ReleasePlanId)[0] + $parameter.displayName | Should -BeExactly 'Release plan ID (0 uses package name based lookup)' + } + + It 'always forwards the optional ID and leaves the SDK PR default to the shared template' { + @($completion.parameters.Keys | Sort-Object) | Should -Be @('ConfigFileDir', 'PackageArtifactName', 'ReleasePlanId' | Sort-Object) + $completion.parameters.ReleasePlanId | Should -BeExactly $idExpression + $completion.parameters.Contains('SdkPullRequest') | Should -BeFalse + } + + It 'does not bind prepare-stage SDK PR outputs in the release completion job' { + @($stage.variables | Where-Object { $_['name'] -eq 'AutoReleaseSdkPullRequestUrl' }).Count | Should -Be 0 + $binding = @(Find-YamlMap $stage.jobs name AutoReleaseSdkPullRequestUrl) + $binding.Count | Should -Be 0 + $completionJob.Contains('variables') | Should -BeFalse + $clientRelay.parameters.Contains('TestPipeline') | Should -BeFalse + $clientRelay.parameters[$templateGuard].TestPipeline | Should -BeTrue + $artifactRelay.parameters.TestPipeline | Should -BeExactly '${{ parameters.TestPipeline }}' + $stage.dependsOn[1][$autoGuard] | Should -Be @('AutoReleasePrepare') + } + + It 'uses the shared empty-value initializer when no explicit SDK PR is supplied' { + $common = ConvertFrom-Yaml (Get-Content -LiteralPath (Join-Path $root 'eng/common/pipelines/templates/steps/mark-release-completion.yml') -Raw) + $initializerKey = '${{ if eq(parameters.SdkPullRequest, '''') }}' + $initializer = @($common.steps | Where-Object { $_.Contains($initializerKey) }) + $initializer.Count | Should -Be 1 + $initializer[0][$initializerKey][0].condition | Should -BeExactly 'and(succeeded(), ne(variables[''Skip.MarkReleaseCompletion''], ''true''), eq(variables[''AutoReleaseSdkPullRequestUrl''], ''''))' + $output = @(& ([scriptblock]::Create($initializer[0][$initializerKey][0].pwsh)) 6>&1 | ForEach-Object { $_.ToString() }) + $output | Should -Contain '##vso[task.setvariable variable=AutoReleaseSdkPullRequestUrl]' + } + + It 'preserves signed and unsigned artifact sources and dependencies' { + $signed = $artifactRelay.parameters['${{ if eq(artifact.signBinaries, true) }}'] + $signed.ArtifactName | Should -BeExactly 'packages_${{ artifact.safeName }}_signed' + $signed.DependsOn | Should -BeExactly 'Sign_${{ artifact.safeName }}' + $signed.SignedArtifactTargetingStage | Should -BeExactly 'Sign_${{ artifact.safeName }}' + $unsigned = $artifactRelay.parameters['${{ else }}'] + $unsigned.ArtifactName | Should -BeExactly '${{ parameters.ArtifactName }}' + $unsigned.DependsOn | Should -BeExactly '${{ parameters.DependsOn }}' + $stage.dependsOn[0] | Should -BeExactly '${{ parameters.DependsOn }}' + } + + It 'preserves completion ordering and manual versus auto publication environments' { + $job = @(Find-YamlMap $artifact job MarkPackageReleaseCompletion)[0] + $job.dependsOn | Should -BeExactly 'PublishPackage' + $publish = @(Find-YamlMap $artifact deployment PublishPackage)[0] + $publish[$autoGuard].environment | Should -BeExactly 'none' + $publish['${{ else }}'].environment | Should -BeExactly '${{ parameters.PublicPublishEnvironment }}' + $publish.dependsOn | Should -BeExactly 'TagRepository' + $publish.condition | Should -BeExactly 'and(succeeded(), ne(variables[''Skip.PublishPackage''], ''true''))' + } + + # Protect the existing safety contract from checked-in YAML, not historical + # objects: shallow CI and source-only copies must run every assertion. + It 'keeps feed and approval overrides opt-in and preserves both template artifacts' { + foreach ($name in @('ReleaseToDevOpsOnly', 'AutoApproveRelease')) { + $parameter = @($entry.parameters | Where-Object name -eq $name) + $parameter.Count | Should -Be 1 + $parameter[0].type | Should -BeExactly 'boolean' + $parameter[0].default | Should -BeFalse + } + $entry.extends.template | Should -BeExactly '../../eng/pipelines/templates/stages/archetype-sdk-client.yml' + $entry.extends.parameters.Contains('PublicFeed') | Should -BeFalse + $entry.extends.parameters.Contains('PublicPublishEnvironment') | Should -BeFalse + $entry.extends.parameters['${{ if eq(parameters.ReleaseToDevOpsOnly, ''true'') }}'].PublicFeed | Should -BeExactly 'public/storage-staging' + $entry.extends.parameters['${{ if eq(parameters.AutoApproveRelease, ''true'') }}'].PublicPublishEnvironment | Should -BeExactly 'none' + $artifacts = @($entry.extends.parameters.Artifacts) + $artifacts.Count | Should -Be 2 + $artifacts[0].name | Should -BeExactly 'azure-template' + $artifacts[0].safeName | Should -BeExactly 'azuretemplate' + $artifacts[0].Contains('signBinaries') | Should -BeFalse + $artifacts[1].name | Should -BeExactly 'azure-template-two' + $artifacts[1].safeName | Should -BeExactly 'azuretemplatetwo' + $artifacts[1].signBinaries | Should -BeTrue + $artifacts[1].skipPublishDocGithubIo | Should -BeTrue + $artifacts[1].skipPublishDocMs | Should -BeTrue + @($entry.trigger.branches.include) -join '|' | Should -BeExactly 'main|hotfix/*|release/*|restapi*' + @($entry.trigger.paths.include) -join '|' | Should -BeExactly 'sdk/template/|scripts/|eng/common/' + } + + It 'keeps client build dependencies, artifact eligibility and non-template defaults' { + @(Find-YamlMap $client template 'archetype-python-release.yml').Count | Should -Be 1 + $clientRelay.parameters.DependsOn | Should -BeExactly 'Build' + $clientRelay.parameters.Artifacts | Should -BeExactly '${{ parameters.Artifacts }}' + $clientRelay.parameters.ArtifactName | Should -BeExactly 'packages_extended' + $clientRelay.parameters.Contains('TestPipeline') | Should -BeFalse + $clientRelay.parameters[$templateGuard].TestPipeline | Should -BeTrue + foreach ($name in @('ServiceDirectory', 'PublicFeed', 'PublicPublishEnvironment', 'DevFeedName')) { + $clientRelay.parameters[$name] | Should -BeExactly ('${{ parameters.' + $name + ' }}') + } + foreach ($contract in @( + @{ Name = 'PublicFeed'; Default = 'PyPi' }, + @{ Name = 'PublicPublishEnvironment'; Default = 'package-publish' }, + @{ Name = 'TestPipeline'; Default = $false } + )) { + $parameter = @($client.parameters | Where-Object name -eq $contract.Name) + $parameter.Count | Should -Be 1 + $parameter[0].default | Should -Be $contract.Default + $release.parameters[$contract.Name] | Should -Be $contract.Default + $parameter = @($artifact.parameters | Where-Object name -eq $contract.Name) + $parameter.Count | Should -Be 1 + $parameter[0].default | Should -Be $contract.Default + } + $build = @(Find-YamlMap $client stage Build) + $build.Count | Should -Be 1 + $build[0].condition | Should -BeExactly 'not(and(eq(${{ parameters.SkipPrValidation }}, true), eq(variables[''Build.Reason''], ''Manual'')))' + } + + It 'keeps internal release eligibility and the auto-release prepare build dependency' { + $releaseGate = '${{ if and(eq(variables[''System.TeamProject''], ''internal''), or(in(variables[''Build.Reason''], ''Manual'', ''''), and(eq(variables[''Build.Reason''], ''IndividualCI''), eq(variables[''Build.SourceBranch''], ''refs/heads/main'')))) }}' + $guarded = @($release.stages | Where-Object { $_.Contains($releaseGate) }) + $guarded.Count | Should -Be 1 + $nodes = $guarded[0][$releaseGate] + $loop = @($nodes | Where-Object { $_.Contains('${{ each artifact in parameters.Artifacts }}') }) + $loop.Count | Should -Be 1 + @(Find-YamlMap $loop[0]['${{ each artifact in parameters.Artifacts }}'] template '/eng/pipelines/templates/stages/release-artifact.yml').Count | Should -Be 1 + $prepareGuard = @($nodes | Where-Object { $_.Contains($autoGuard) }) + $prepareGuard.Count | Should -Be 1 + $prepare = @($prepareGuard[0][$autoGuard]) + $prepare.Count | Should -Be 1 + $prepare[0].template | Should -BeExactly '/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml' + @($prepare[0].parameters.DependsOn) | Should -Be @('${{ parameters.DependsOn }}') + $prepare[0].parameters.Artifacts | Should -BeExactly '${{ parameters.Artifacts }}' + $prepare[0].parameters.Condition | Should -BeExactly 'and(succeeded(), ne(variables[''SetDevVersion''], ''true''), ne(variables[''Skip.Release''], ''true''), ne(variables[''Build.Repository.Name''], ''Azure/azure-sdk-for-python-pr''))' + @($stage.dependsOn).Count | Should -Be 2 + @($stage.dependsOn[1].Keys) | Should -Be @($autoGuard) + @(Find-YamlMap $release template '/eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml').Count | Should -Be 1 + } + + It 'keeps all signed and unsigned manual and automatic release skip gates' { + $safe = 'ne(variables[''SetDevVersion''], ''true''), ne(variables[''Skip.Release''], ''true''), ne(variables[''Build.Repository.Name''], ''Azure/azure-sdk-for-python-pr'')' + $eligible = 'eq(dependencies.AutoReleasePrepare.outputs[''ResolveAutoReleasePackages.resolve.ReleaseArtifact_${{ parameters.Artifact.safeName }}''], ''true'')' + $targeted = 'eq(dependencies.${{ parameters.SignedArtifactTargetingStage }}.outputs[''ResolveTargeting.resolve.ArtifactTargeted''], ''true'')' + $stage['${{ if and(eq(variables[''Build.Reason''], ''IndividualCI''), eq(variables[''Build.SourceBranch''], ''refs/heads/main''), ne(parameters.SignedArtifactTargetingStage, '''')) }}'].condition | Should -BeExactly "and(succeeded(), $eligible, $targeted, $safe)" + $stage['${{ elseif and(eq(variables[''Build.Reason''], ''IndividualCI''), eq(variables[''Build.SourceBranch''], ''refs/heads/main'')) }}'].condition | Should -BeExactly "and(succeeded(), $eligible, $safe)" + $stage['${{ elseif ne(parameters.SignedArtifactTargetingStage, '''') }}'].condition | Should -BeExactly "and(succeeded(), $targeted, $safe)" + $stage['${{ else }}'].condition | Should -BeExactly "and(succeeded(), $safe)" + $signingGate = '${{ if and(eq(variables[''System.TeamProject''], ''internal''), ne(variables[''Build.Reason''], ''PullRequest'')) }}' + $signing = @($release.stages | Where-Object { $_.Contains($signingGate) }) + $signing.Count | Should -Be 1 + $signingLoop = $signing[0][$signingGate][0]['${{ each artifact in parameters.Artifacts }}'] + $sign = @($signingLoop[0]['${{ if eq(artifact.signBinaries, true) }}']) + $sign.Count | Should -Be 1 + $sign[0].template | Should -BeExactly '/eng/pipelines/templates/stages/sign-binaries.yml' + $sign[0].parameters.Artifact | Should -BeExactly '${{ artifact }}' + $sign[0].parameters.DependsOn | Should -BeExactly '${{ parameters.DependsOn }}' + $unsigned = $artifactRelay.parameters['${{ else }}'] + $unsigned.Contains('SignedArtifactTargetingStage') | Should -BeFalse + $parameter = @($artifact.parameters | Where-Object name -eq SignedArtifactTargetingStage) + $parameter.Count | Should -Be 1 + $parameter[0].default | Should -BeExactly '' + } + + It 'keeps signed and unsigned integration routing and the dev-feed skip gate' { + $guard = @($release.stages | Where-Object { $_.Contains('${{ if eq(variables[''System.TeamProject''], ''internal'') }}') }) + $guard.Count | Should -Be 1 + $integration = @(Find-YamlMap $guard[0]['${{ if eq(variables[''System.TeamProject''], ''internal'') }}'] stage Integration) + $integration.Count | Should -Be 1 + $integration[0].condition | Should -BeExactly 'succeededOrFailed(''${{parameters.DependsOn}}'')' + $integration[0].dependsOn[0] | Should -BeExactly '${{ parameters.DependsOn }}' + $notPr = '${{ if ne(variables[''Build.Reason''], ''PullRequest'') }}' + $signedDependencies = $integration[0].dependsOn[1][$notPr][0]['${{ each artifact in parameters.Artifacts }}'][0]['${{ if eq(artifact.signBinaries, true) }}'] + @($signedDependencies) | Should -Be @('Sign_${{ artifact.safeName }}') + $publish = @(Find-YamlMap $integration[0] job PublishPackages) + $publish.Count | Should -Be 1 + $publish[0].condition | Should -BeExactly 'or(eq(variables[''SetDevVersion''], ''true''), and(eq(variables[''Build.Reason''],''Schedule''), eq(variables[''System.TeamProject''], ''internal'')))' + $loop = @($publish[0].steps | Where-Object { $_.Contains('${{ each artifact in parameters.Artifacts }}') }) + $loop.Count | Should -Be 1 + $alpha = @(Find-YamlMap $loop[0]['${{ each artifact in parameters.Artifacts }}'][0]['${{if ne(artifact.skipPublishDevFeed, ''true'')}}'] template '/eng/pipelines/templates/steps/publish-alpha-package.yml') + $alpha.Count | Should -Be 1 + $alpha[0].parameters['${{ if and(eq(artifact.signBinaries, true), ne(variables[''Build.Reason''], ''PullRequest'')) }}'].PackagePath | Should -BeExactly '$(Pipeline.Workspace)/packages_${{ artifact.safeName }}_signed/${{ artifact.name }}' + $alpha[0].parameters['${{ else }}'].PackagePath | Should -BeExactly '$(Pipeline.Workspace)/${{ parameters.ArtifactName }}/${{ artifact.name }}' + $downloads = @($publish[0].steps | Where-Object { $_.Contains($notPr) }) + $downloads.Count | Should -Be 1 + $signedDownload = $downloads[0][$notPr][0]['${{ each artifact in parameters.Artifacts }}'][0]['${{ if eq(artifact.signBinaries, true) }}'][0] + $signedDownload.artifact | Should -BeExactly 'packages_${{ artifact.safeName }}_signed' + $signedDownload.continueOnError | Should -BeTrue + } + + It 'keeps publication artifact inputs, approval environments and downstream skip gates' { + $publishGuard = @($stage.jobs | Where-Object { $_.Contains('${{if ne(parameters.Artifact.skipPublishPackage, ''true'')}}') }) + $publishGuard.Count | Should -Be 1 + $publish = @(Find-YamlMap $publishGuard[0]['${{if ne(parameters.Artifact.skipPublishPackage, ''true'')}}'] deployment PublishPackage) + $publish.Count | Should -Be 1 + $publish[0].Contains('environment') | Should -BeFalse + $publish[0].templateContext.type | Should -BeExactly 'releaseJob' + $publish[0].templateContext.isProduction | Should -BeTrue + $inputs = @($publish[0].templateContext.inputs) + $inputs.Count | Should -Be 2 + $inputs[0].artifactName | Should -BeExactly 'release_artifact' + $inputs[1].artifactName | Should -BeExactly '${{ parameters.ArtifactName }}' + $inputs[1].targetPath | Should -BeExactly '$(Pipeline.Workspace)/${{ parameters.ArtifactName }}' + $steps = $publish[0].strategy.runOnce.deploy.steps + $public = @($steps | Where-Object { $_.Contains('${{ if eq(parameters.PublicFeed, ''PyPi'') }}') }) + $public.Count | Should -Be 1 + @(Find-YamlMap $public[0]['${{ if eq(parameters.PublicFeed, ''PyPi'') }}'] template '/eng/pipelines/templates/steps/esrp-publish.yml')[0].parameters.targetFolder | Should -BeExactly '$(Pipeline.Workspace)/esrp-release/${{parameters.ArtifactName}}/${{parameters.Artifact.name}}' + $private = @($steps | Where-Object { $_.Contains('${{ if ne(parameters.PublicFeed, ''PyPi'') }}') }) + $private.Count | Should -Be 1 + @(Find-YamlMap $private[0]['${{ if ne(parameters.PublicFeed, ''PyPi'') }}'] task 'TwineAuthenticate@0')[0].inputs.artifactFeeds | Should -BeExactly '${{parameters.PublicFeed}}' + @(Find-YamlMap $artifact job TagRepository)[0].condition | Should -BeExactly 'and(succeeded(), ne(variables[''Skip.TagRepository''], ''true''))' + @(Find-YamlMap $artifact job UpdatePackageVersion)[0].condition | Should -BeExactly 'and(succeeded(), ne(variables[''Skip.UpdatePackageVersion''], ''true''))' + foreach ($contract in @( + @{ Gate = '${{if ne(parameters.Artifact.skipPublishDocGithubIo, ''true'')}}'; Job = 'PublishGitHubIODocs' }, + @{ Gate = '${{if ne(parameters.Artifact.skipPublishDocMs, ''true'')}}'; Job = 'PublishDocs' } + )) { + $guarded = @($stage.jobs | Where-Object { $_.Contains($contract.Gate) }) + $guarded.Count | Should -Be 1 + $job = @(Find-YamlMap $guarded[0][$contract.Gate] job $contract.Job) + $job.Count | Should -Be 1 + $job[0].dependsOn | Should -BeExactly 'PublishPackage' + ($job[0].condition -replace '\s', '') | Should -BeExactly 'and(succeeded(),ne(variables[''Skip.PublishDocs''],''true''),ne(variables[''Build.Repository.Name''],''Azure/azure-sdk-for-python-pr''))' + } + $arh = @(Find-YamlMap $artifact displayName 'Mark Package Released') + $arh.Count | Should -Be 1 + ($arh[0].condition -replace '\s', '') | Should -BeExactly 'and(succeeded(),ne(variables[''IsManagementPackage''],''true''),not(and(eq(variables[''Skip.MarkPackageReleased''],''true''),eq(variables[''IsRequesterAuthorizedToSkipApiReview''],''true''))))' + } + + It 'keeps non-template completion arguments and the shared completion skip gate explicit' { + $completion.parameters.ConfigFileDir | Should -BeExactly '$(Pipeline.Workspace)/${{parameters.ArtifactName}}/PackageInfo' + $completion.parameters.PackageArtifactName | Should -BeExactly '${{parameters.Artifact.name}}' + $common = ConvertFrom-Yaml (Get-Content -LiteralPath (Join-Path $root 'eng/common/pipelines/templates/steps/mark-release-completion.yml') -Raw) + @($common.parameters.Keys | Sort-Object) | Should -Be @('ConfigFileDir', 'PackageArtifactName', 'ReleasePlanId', 'SdkPullRequest', 'SourceRootPath') + $common.parameters.ConfigFileDir | Should -BeExactly '' + $common.parameters.PackageArtifactName | Should -BeExactly '' + $common.parameters.SourceRootPath | Should -BeExactly '$(Build.SourcesDirectory)' + $common.parameters.ReleasePlanId | Should -Be 0 + $common.parameters.SdkPullRequest | Should -BeExactly '' + $mark = @(Find-YamlMap $common task 'AzureCLI@2') + $mark.Count | Should -Be 1 + $mark[0].condition | Should -BeExactly 'and(succeeded(), ne(variables[''Skip.MarkReleaseCompletion''], ''true''))' + $mark[0].continueOnError | Should -BeTrue + $mark[0].inputs.scriptPath | Should -BeExactly '${{ parameters.SourceRootPath }}/eng/common/scripts/Mark-ReleasePlanCompletion.ps1' + ($mark[0].inputs.arguments -replace '\s+', ' ').Trim() | Should -BeExactly ('-PackageInfoFilePath ''${{ parameters.ConfigFileDir }}/${{ parameters.PackageArtifactName }}.json'' -AzsdkExePath ''$(AZSDK)'' ' + + '-ReleasePlanId ''${{ parameters.ReleasePlanId }}'' -SdkPullRequest ''${{ coalesce(parameters.SdkPullRequest, ''$(AutoReleaseSdkPullRequestUrl)'') }}'' ' + + '-PipelineUrl "$(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)"') + $common = ConvertFrom-Yaml (Get-Content -LiteralPath (Join-Path $root 'eng/common/pipelines/templates/stages/archetype-auto-release-prepare.yml') -Raw) + $prepare = @(Find-YamlMap $common stage AutoReleasePrepare) + $prepare.Count | Should -Be 1 + $prepare[0].dependsOn | Should -BeExactly '${{ parameters.DependsOn }}' + $prepare[0].condition | Should -BeExactly '${{ parameters.Condition }}' + $resolve = @(Find-YamlMap $prepare[0] job ResolveAutoReleasePackages) + $resolve.Count | Should -Be 1 + @(Find-YamlMap $resolve[0] name resolve).Count | Should -Be 1 + } + + It 'preserves raw string for downstream validation on both paths' -TestCases @( + @{ Id = '0' }, @{ Id = '35307' }, @{ Id = '2147483647' }, @{ Id = '100.6' } + ) { + param([string]$Id) + $value = (ConvertFrom-Yaml "ReleasePlanId: '$Id'").ReleasePlanId + foreach ($relay in @($entry.extends, $clientRelay, $artifactRelay)) { + $relay.parameters.ReleasePlanId | Should -BeExactly $idExpression + $value | Should -BeOfType ([string]) + $value | Should -BeExactly $Id + } + $completion.parameters.ReleasePlanId.Replace($idExpression, $value) | Should -BeExactly $Id + $completion.parameters.Contains('SdkPullRequest') | Should -BeFalse + } +} diff --git a/eng/scripts/tests/test_save_package_api_hash.py b/eng/scripts/tests/test_save_package_api_hash.py new file mode 100644 index 000000000000..ab0f39cb1395 --- /dev/null +++ b/eng/scripts/tests/test_save_package_api_hash.py @@ -0,0 +1,117 @@ +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import yaml + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +import save_package_api_hash + + +class SavePackageApiHashTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.artifact_dir = pathlib.Path(self.temp_dir.name) + self.package_info_dir = self.artifact_dir / "PackageInfo" + self.package_info_dir.mkdir() + + def write_package_info(self, name="azure-mgmt-test", **properties): + path = self.package_info_dir / f"{name}.json" + path.write_text( + json.dumps({"Name": name, "Version": "1.0.0", "SdkType": "mgmt", **properties}), + encoding="utf-8-sig", + ) + return path + + def run_main(self): + with patch.object( + sys, + "argv", + [ + "save_package_api_hash.py", + "--artifact-staging-directory", + str(self.artifact_dir), + "--repo-root", + str(self.artifact_dir), + ], + ): + save_package_api_hash.main() + + def test_missing_tokens_leave_package_info_unchanged(self): + for name, sdk_type in (("azure-mgmt-test", "mgmt"), ("azure-test", "client")): + for properties in ({}, {"ApiHash": None}, {"ApiHash": ""}, {"ApiHash": " "}): + with self.subTest(name=name, properties=properties): + path = self.write_package_info(name, SdkType=sdk_type, **properties) + original = path.read_bytes() + with patch.object(save_package_api_hash.subprocess, "run") as export: + self.run_main() + export.assert_not_called() + self.assertEqual(path.read_bytes(), original) + + def test_missing_tokens_preserve_existing_hash(self): + path = self.write_package_info(ApiHash="existing-hash") + original = path.read_bytes() + self.run_main() + self.assertEqual(json.loads(path.read_text(encoding="utf-8-sig"))["ApiHash"], "existing-hash") + self.assertEqual(path.read_bytes(), original) + + def test_generated_hash_and_missing_tokens_in_same_run(self): + management_path = self.write_package_info() + generated_path = self.write_package_info("azure-test", ApiHash="existing-hash") + package_dir = self.artifact_dir / "azure-test" + package_dir.mkdir() + token_file = package_dir / "azure-test_python.json" + token_file.write_text("{}", encoding="utf-8") + (package_dir / "api.md").write_text("```\nclass Test:\n```\n", encoding="utf-8") + + with patch.object(save_package_api_hash.subprocess, "run") as export: + self.run_main() + + export.assert_called_once_with( + [ + "pwsh", + str(self.artifact_dir / "eng" / "common" / "scripts" / "Export-APIViewMarkdown.ps1"), + "-TokenJsonPath", + str(token_file), + "-OutputPath", + str(package_dir), + ], + check=True, + ) + metadata = yaml.safe_load((package_dir / "api.metadata.yml").read_text(encoding="utf-8")) + self.assertEqual(json.loads(generated_path.read_text(encoding="utf-8"))["ApiHash"], metadata["apiHash"]) + self.assertEqual(len(metadata["apiHash"]), 64) + self.assertNotIn("ApiHash", json.loads(management_path.read_text(encoding="utf-8-sig"))) + + def test_invalid_generated_metadata_still_fails(self): + path = self.write_package_info() + metadata_path = self.artifact_dir / "api.metadata.yml" + original = path.read_bytes() + for metadata in ({}, {"apiHash": None}, {"apiHash": ""}, {"apiHash": " "}, {"apiHash": 123}): + with self.subTest(metadata=metadata): + metadata_path.write_text(yaml.safe_dump(metadata), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "apiHash was not found"): + save_package_api_hash.update_package_info(metadata_path, path) + self.assertEqual(path.read_bytes(), original) + + def test_export_failure_is_propagated(self): + path = self.write_package_info("azure-test") + package_dir = self.artifact_dir / "azure-test" + package_dir.mkdir() + (package_dir / "azure-test_python.json").write_text("{}", encoding="utf-8") + with patch.object( + save_package_api_hash.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "pwsh") + ): + with self.assertRaises(subprocess.CalledProcessError): + self.run_main() + self.assertNotIn("ApiHash", json.loads(path.read_text(encoding="utf-8-sig"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/eng/tools/azure-sdk-tools/README.md b/eng/tools/azure-sdk-tools/README.md index 47bfa5748a61..07423b44455a 100644 --- a/eng/tools/azure-sdk-tools/README.md +++ b/eng/tools/azure-sdk-tools/README.md @@ -95,6 +95,29 @@ sdk_build azure-storage* --devbuild=True sdk_build azure* --service=core -d "" ``` +### 1ES proxy trust + +Artifact-build and build-test pipelines use +[`run_with_1es_proxy_trust.py`](../../scripts/run_with_1es_proxy_trust.py) for proxy +trust on Windows and Linux. Trust is enabled only in Azure Pipelines with +`1ESNI_CONFIG_PATH` and `HTTPS_PROXY` (or `https_proxy`) set. + +Infrastructure must supply the proxy CA file through `NODE_EXTRA_CA_CERTS`. +The wrapper combines it with the existing `SSL_CERT_FILE` or certifi roots, +validates the certificates, and supplies a temporary `SSL_CERT_FILE` to child +processes. Missing or invalid certificates fail the command. The bundle is +removed on exit; system trust and package sources are unchanged. + +On Linux, the bundle is mounted read-only inside cibuildwheel containers and +proxy settings are forwarded without replacing package environment settings. +Container-engine options come from `CIBW_CONTAINER_ENGINE_LINUX` or +`CIBW_CONTAINER_ENGINE` (default: Docker); use these variables instead of +package-level `container-engine` options. Container image pulls still require +infrastructure-configured trust. + +Bare `python` and `python.exe` child commands reuse the wrapper's interpreter, +preserving the active virtual environment. Other commands are unchanged. + ## Using "versioning" modules On top of assembling packages, azure-sdk-tools also can be used to complete various tasks with respect to version "maintenance". There are three primary entrypoints, and each fulfills a purpose in the repository. diff --git a/eng/tools/azure-sdk-tools/ci_tools/build.py b/eng/tools/azure-sdk-tools/ci_tools/build.py index 44177c20c026..5c335bb3f16e 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/build.py +++ b/eng/tools/azure-sdk-tools/ci_tools/build.py @@ -9,6 +9,7 @@ from ci_tools.versioning.version_shared import set_version_py, set_dev_classifier from ci_tools.versioning.version_set_dev import get_dev_version, format_build_id from ci_tools.logging import logger, configure_logging, run_logged +from ci_tools.windows_wheels import windows_wheel_config def build_package() -> None: @@ -223,7 +224,7 @@ def create_package( """ Builds a wheel and/or sdist file given a setup.py, pyproject.toml, or directory containing either. - For packages with compiled extensions (ext_modules): + For packages with compiled extensions (ext_modules or a [tool.cibuildwheel] table): - setup.py: uses cibuildwheel to build platform-specific wheels - pyproject.toml: uses cibuildwheel to build platform-specific wheels (respects [tool.cibuildwheel] config) @@ -237,19 +238,25 @@ def create_package( setup_parsed = ParsedSetup.from_path(setup_directory_or_file) should_log_build_output = logger.getEffectiveLevel() <= logging.DEBUG + # Backends such as Maturin compile extensions without declaring setuptools ext_modules. + # Honor their cibuildwheel matrix instead of building only a host wheel. + is_compiled = bool(setup_parsed.ext_modules) or setup_parsed.uses_cibuildwheel if setup_parsed.is_pyproject: # when building with pyproject, check if package has compiled extensions - if enable_wheel and setup_parsed.ext_modules: - # Use cibuildwheel for compiled extensions (respects [tool.cibuildwheel] config) - run_logged( - [sys.executable, "-m", "cibuildwheel", "--output-dir", dist], - cwd=setup_parsed.folder, - check=True, - should_stream_to_console=should_log_build_output, - ) + if is_compiled: + if enable_wheel: + # Use cibuildwheel for compiled extensions (respects [tool.cibuildwheel] config) + with windows_wheel_config(setup_parsed.folder) as (arguments, environment): + run_logged( + [sys.executable, "-m", "cibuildwheel", "--output-dir", dist, *arguments], + cwd=setup_parsed.folder, + check=True, + should_stream_to_console=should_log_build_output, + **environment, + ) if enable_sdist: - # Build sdist separately with python -m build + # Build isolation installs package-specific backends such as Maturin. run_logged( [sys.executable, "-m", "build", "-s", "-o", dist], cwd=setup_parsed.folder, @@ -289,13 +296,15 @@ def create_package( ) else: if enable_wheel: - if setup_parsed.ext_modules: - run_logged( - [sys.executable, "-m", "cibuildwheel", "--output-dir", dist], - cwd=setup_parsed.folder, - check=True, - should_stream_to_console=should_log_build_output, - ) + if is_compiled: + with windows_wheel_config(setup_parsed.folder) as (arguments, environment): + run_logged( + [sys.executable, "-m", "cibuildwheel", "--output-dir", dist, *arguments], + cwd=setup_parsed.folder, + check=True, + should_stream_to_console=should_log_build_output, + **environment, + ) else: run_logged( [sys.executable, "setup.py", "bdist_wheel", "-d", dist], diff --git a/eng/tools/azure-sdk-tools/ci_tools/environment_exclusions.py b/eng/tools/azure-sdk-tools/ci_tools/environment_exclusions.py index 01e3a4bab669..ea9d32e0ec4e 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/environment_exclusions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/environment_exclusions.py @@ -46,10 +46,11 @@ def is_check_enabled(package_path: str, check: str, default: Any = True) -> bool Single-use function to evaluate whether or not a given check should run against a package. In order: - - Checks _OPT_OUT for package name. - - Honors override variable if one is present: _. (Note the _ in the package name, `-` is not a valid env variable character.) - Checks for `ci_enabled` flag in pyproject.toml and skips all checks if set to false. - - Finally falls back to the pyproject.toml at package root (if one exists) for a tools setting enabling/disabling . + Public and weekly analyze builds ignore this flag. + - Honors override variable if one is present: _. (Replace `-` with `_` in the package name.) + - Falls back to the pyproject.toml at package root (if one exists) for a tools setting enabling/disabling . + - Rejects a disabled apistub check instead of allowing a package-level opt-out. """ if package_path.endswith("setup.py"): package_path = os.path.dirname(package_path) @@ -62,7 +63,14 @@ def is_check_enabled(package_path: str, check: str, default: Any = True) -> bool return False # now pull the new pyproject.toml configuration - config = get_config_setting(package_path, check.strip().lower(), default) + check = check.strip().lower() + config = get_config_setting(package_path, check, default) + if check == "apistub" and not config: + raise ValueError( + f"apistub is required for {package_path} and cannot be disabled. " + "Remove 'apistub = false' from [tool.azure-sdk-build] in pyproject.toml " + "and any disabling _APISTUB environment override." + ) return config diff --git a/eng/tools/azure-sdk-tools/ci_tools/functions.py b/eng/tools/azure-sdk-tools/ci_tools/functions.py index 8b07f88aa44c..0bdb1c42e334 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/functions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/functions.py @@ -324,7 +324,7 @@ def update_requires(setup_path, requires_dict): contents = setup_file.readlines() # find and replace all existing package requirement with new requirement - for i in range(0, len(contents) - 1): + for i in range(len(contents)): keys = [k for k in requires_dict.keys() if k in contents[i]] for key in keys: contents[i] = contents[i].replace(key, requires_dict[key]) @@ -424,16 +424,16 @@ def process_requires(setup_py_path: str, is_dev_build: bool = False): """ pkg_details = ParsedSetup.from_path(setup_py_path) - azure_requirements = [Requirement(r) for r in pkg_details.requires if r.startswith("azure")] + azure_requirements = [r for r in pkg_details.requires if r.startswith("azure")] # Find package requirements that are not available on PyPI requirement_to_update = {} - for req in azure_requirements: + for old_req in azure_requirements: + req = Requirement(old_req) pkg_name = req.name spec = SpecifierSet(str(req).replace(pkg_name, "")) if not is_required_version_on_pypi(pkg_name, spec) or is_dev_build: - old_req = str(req) version = get_version_from_repo(pkg_name) base_version = get_base_version(pkg_name) logging.info("Updating version {0} in requirement {1} to dev build version".format(version, old_req)) diff --git a/eng/tools/azure-sdk-tools/ci_tools/logging/__init__.py b/eng/tools/azure-sdk-tools/ci_tools/logging/__init__.py index ff6e22e6fe22..8dbf79de4909 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/logging/__init__.py +++ b/eng/tools/azure-sdk-tools/ci_tools/logging/__init__.py @@ -5,6 +5,7 @@ import logging from logging import Logger +from typing import Mapping from ci_tools.variables import get_log_directory, in_ci @@ -79,20 +80,36 @@ def run_logged_to_file(*args, prefix="", **kwargs): subprocess.run(*args, **kwargs, stdout=log_output, stderr=log_output) -def run_logged(cmd: list[str], cwd: str, check: bool, should_stream_to_console: bool): +def run_logged( + cmd: list[str], + cwd: str, + check: bool, + should_stream_to_console: bool, + env: Mapping[str, str] | None = None, +): """ Runs a command, logging output to subprocess.PIPE or streaming live to console based on log level. Regardless of `should_stream_to_console`, if the command fails, the captured output will be logged. + If `env` is omitted, the command inherits the current process environment. """ try: if should_stream_to_console: # Stream live, no capturing - return subprocess.run(cmd, cwd=cwd, check=check, text=True, stderr=subprocess.STDOUT) + return subprocess.run(cmd, cwd=cwd, check=check, text=True, stderr=subprocess.STDOUT, env=env) else: - # Capture merged output but don't print unless there's a failure + # Native build tools emit UTF-8 even when Windows defaults to a legacy code page. + # Preserve unexpected bytes as escapes rather than masking the command's exit status. return subprocess.run( - cmd, cwd=cwd, check=check, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + cmd, + cwd=cwd, + check=check, + text=True, + encoding="utf-8", + errors="backslashreplace", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, ) except subprocess.CalledProcessError as e: logger.error(f"Command failed: {' '.join(cmd)}") diff --git a/eng/tools/azure-sdk-tools/ci_tools/parsing/parse_functions.py b/eng/tools/azure-sdk-tools/ci_tools/parsing/parse_functions.py index 2830ee15ae70..48c7619b7f7c 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/parsing/parse_functions.py +++ b/eng/tools/azure-sdk-tools/ci_tools/parsing/parse_functions.py @@ -308,6 +308,7 @@ def __init__( self.is_pyproject = self.setup_filename.endswith(".toml") self.folder = os.path.dirname(self.setup_filename) + self.uses_cibuildwheel: bool = has_cibuildwheel_config(self.folder) @classmethod def from_path(cls, parse_directory_or_file: str): @@ -828,6 +829,20 @@ def parse_setup( return result +def has_cibuildwheel_config(folder: str) -> bool: + """Return whether the package opts into cibuildwheel, including non-setuptools backends.""" + pyproject_filename = os.path.join(folder, "pyproject.toml") + if not os.path.exists(pyproject_filename): + return False + + config = get_value_from_dict(get_pyproject_dict(pyproject_filename), "tool.cibuildwheel") + if config is None: + return False + if not isinstance(config, dict): + raise ValueError(f"Expected a [tool.cibuildwheel] table in {pyproject_filename}") + return True + + def get_pyproject_dict(pyproject_file: str) -> Dict[str, Any]: """ Given a pyproject.toml file, returns a dictionary of a target section. Defaults to `project` section. diff --git a/eng/tools/azure-sdk-tools/ci_tools/venv.py b/eng/tools/azure-sdk-tools/ci_tools/venv.py index dfda7c8ee8b9..e546844d66ba 100644 --- a/eng/tools/azure-sdk-tools/ci_tools/venv.py +++ b/eng/tools/azure-sdk-tools/ci_tools/venv.py @@ -9,13 +9,24 @@ * anything else (default ``"pip"``) → uses ``python -m venv`` / ``python -m pip`` """ +import json import os -import re import subprocess import sys from typing import List, Optional +def _enable_uv_native_tls() -> None: + """Ensure uv consults the OS certificate store instead of its bundled webpki-roots. + + Some CI agents route HTTPS through a TLS-inspecting proxy whose root CA is trusted by the + OS but not by uv's bundled roots, causing 'invalid peer certificate: UnknownIssuer' errors + when uv downloads interpreters (python-build-standalone) or packages. Respects an explicit + user override via setdefault. + """ + os.environ.setdefault("UV_SYSTEM_CERTS", "1") + + def get_venv_call(python_exe: Optional[str] = None, python_version: Optional[str] = None) -> List[str]: """Determine whether to use 'uv venv' or regular 'python -m venv' based on environment. @@ -32,6 +43,7 @@ def get_venv_call(python_exe: Optional[str] = None, python_version: Optional[str # soon we will change this to default to uv if pip_impl == "uv": + _enable_uv_native_tls() cmd = ["uv", "venv"] if python_version: cmd += ["--python", python_version] @@ -52,6 +64,7 @@ def get_pip_command(python_exe: Optional[str] = None) -> List[str]: # soon we will change this to default to uv if pip_impl == "uv": + _enable_uv_native_tls() return ["uv", "pip"] else: return [python_exe if python_exe else sys.executable, "-m", "pip"] @@ -200,13 +213,12 @@ def run_pip_freeze(python_executable: Optional[str] = None) -> List[str]: def get_pip_list_output(python_executable: Optional[str] = None): - """Uses the invoking python executable to get the output from pip list.""" - pip_output = run_pip_freeze(python_executable) - - collected_output = {} - for line in pip_output: - if "==" in line: - package, version = re.split("==", line) - collected_output[package] = version + """List installed versions, including direct-URL and editable installs, in the requested environment.""" + executable = python_executable or sys.executable + pip_cmd = get_pip_command(executable) + command = pip_cmd + ["list", "--format=json", "--disable-pip-version-check"] + if pip_cmd[0] == "uv": + command += ["--python", executable] - return collected_output + output = subprocess.check_output(command, text=True) + return {package["name"]: package["version"] for package in json.loads(output)} diff --git a/eng/tools/azure-sdk-tools/ci_tools/windows_wheels.py b/eng/tools/azure-sdk-tools/ci_tools/windows_wheels.py new file mode 100644 index 000000000000..80ecac991b2b --- /dev/null +++ b/eng/tools/azure-sdk-tools/ci_tools/windows_wheels.py @@ -0,0 +1,156 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +"""Provision selected Windows interpreters using the pipeline's approved NuGet feed.""" + +from contextlib import contextmanager +from importlib import import_module +from importlib.util import find_spec +import os +from pathlib import Path +import platform +import shlex +import subprocess +import sys +from tempfile import TemporaryDirectory + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +def _write_pyo3_config(directory, configuration, libraries): + """Write target Python metadata so Maturin does not inspect the host Python.""" + major, minor = configuration.version.split(".")[:2] + free_threaded = "t-" in configuration.identifier + abi_flags = "t" if free_threaded else "" + build_flags = "Py_GIL_DISABLED" if free_threaded else "" + config = [ + "implementation=CPython", + f"version={major}.{minor}", + "shared=true", + f"build_flags={build_flags}", + "suppress_build_script_link_lines=false", + "pointer_width=64", + f"lib_dir={libraries.as_posix()}", + f"ext_suffix=.cp{major}{minor}{abi_flags}-win_arm64.pyd", + ] + # Maturin 1.15 derives the free-threaded wheel ABI tag from this field. + if free_threaded: + config.append("abiflags=t") + path = directory / f"pyo3-{configuration.identifier}.txt" + path.write_text("\n".join(config) + "\n", encoding="utf-8") + return path + + +@contextmanager +def windows_wheel_config(package_dir): + """Yield cibuildwheel arguments/environment, leaving non-pipeline builds unchanged.""" + # The pipeline sets this variable after authenticating to the approved feed. + # Keep ordinary local builds and non-Windows builds on cibuildwheel's normal path. + feed = os.environ.get("AZURESDK_PYTHON_NUGET_FEED") + if sys.platform != "win32" or not feed: + yield [], {} + return + + from cibuildwheel.options import CommandLineArguments, EnvironmentFormat, Options + + # cibuildwheel 3+ moved its platform modules; Python 3.10 hosts still resolve 2.x. + module = "cibuildwheel.platforms.windows" if find_spec("cibuildwheel.platforms") else "cibuildwheel.windows" + windows = import_module(module) + arguments = CommandLineArguments.defaults() + arguments.package_dir = Path(package_dir) + options = Options(platform="windows", command_line_arguments=arguments, env=os.environ) + # Let cibuildwheel combine package config and CIBW_* variables. This keeps + # build, skip, requires-python, and architecture selection in one authority. + configurations = windows.get_python_configurations(options.globals.build_selector, options.globals.architectures) + cache = Path(os.environ["CIBW_CACHE_PATH"]) / "nuget-cpython" + cache.mkdir(parents=True, exist_ok=True) + installed = set() + arm64_libraries = {} + + for configuration in configurations: + if not configuration.identifier.startswith("cp"): + continue + architectures = [configuration.arch] + # An ARM64 wheel built on an AMD64 agent needs two Python packages: + # ARM64 provides the target import libraries, while AMD64 runs the build tools. + if configuration.arch == "ARM64" and platform.machine() != "ARM64": + architectures.append(platform.machine()) + for arch in architectures: + # Ask cibuildwheel for its NuGet package name so free-threaded and + # architecture naming stays aligned with the installed version. + package = windows.get_nuget_args(configuration.version, arch, "t-" in configuration.identifier, cache)[0] + tools = cache / f"{package}.{configuration.version}" / "tools" + if tools not in installed: + if not tools.exists(): + subprocess.run( + [ + "nuget", + "install", + package, + "-Version", + configuration.version, + "-Source", + feed, + "-OutputDirectory", + str(cache), + "-NonInteractive", + ], + check=True, + ) + if not (tools / "python.exe").is_file(): + raise RuntimeError(f"Expected CPython executable at {tools / 'python.exe'}") + installed.add(tools) + if arch == "ARM64" and os.environ.get("AZURESDK_PYTHON_ARM64_LIBS", "").lower() == "true": + # PyO3 must link against the target Python's import library + libraries = tools / "libs" + suffix = "t" if "t-" in configuration.identifier else "" + library = libraries / f"python{''.join(configuration.version.split('.')[:2])}{suffix}.lib" + if not library.is_file(): + raise RuntimeError(f"Expected ARM64 Python import library at {library}") + arm64_libraries[configuration.identifier] = libraries + + if not arm64_libraries: + # Interpreter packages are now cached for cibuildwheel. A temporary + # config is only needed when target-specific ARM64 library paths are required. + yield [], {} + return + + config = {} + if options.config_file_path: + with options.config_file_path.open("rb") as source: + config = tomllib.load(source) + # Preserve each target's resolved environment, adding its matching Python + # import-library path only to ARM64 targets. + overrides = config.setdefault("tool", {}).setdefault("cibuildwheel", {}).setdefault("overrides", []) + # Keep the generated config alive while cibuildwheel runs. TemporaryDirectory + # removes it and the PyO3 target configs after success or failure. + with TemporaryDirectory(prefix="azsdk-cibw-") as directory: + directory = Path(directory) + for configuration in configurations: + with options.reader.identifier(configuration.identifier): + environment = options.reader.get("environment", option_format=EnvironmentFormat()) + if configuration.identifier in arm64_libraries: + libraries = arm64_libraries[configuration.identifier] + pyo3_config = _write_pyo3_config(directory, configuration, libraries) + # PYTHON_ARM64_LIB_DIR supports other Rust build tooling. Maturin + # reads the explicit target metadata instead of inspecting AMD64 Python. + environment += ( + f" PYTHON_ARM64_LIB_DIR={shlex.quote(libraries.as_posix())}" + f" PYO3_CONFIG_FILE={shlex.quote(pyo3_config.as_posix())}" + ) + overrides.append({"select": configuration.identifier, "environment": environment}) + + environment = os.environ.copy() + environment.pop("CIBW_ENVIRONMENT", None) + environment.pop("CIBW_ENVIRONMENT_WINDOWS", None) + config_path = directory / "pyproject.toml" + config_path.write_text(tomli_w.dumps(config), encoding="utf-8") + yield ["--config-file", str(config_path)], {"env": environment} diff --git a/eng/tools/azure-sdk-tools/packaging_tools/sdk_changelog.py b/eng/tools/azure-sdk-tools/packaging_tools/sdk_changelog.py index 0f634e3f8588..463d1a37a675 100644 --- a/eng/tools/azure-sdk-tools/packaging_tools/sdk_changelog.py +++ b/eng/tools/azure-sdk-tools/packaging_tools/sdk_changelog.py @@ -9,6 +9,7 @@ import sys import time +from packaging.version import Version from typing import Any, Optional from .package_utils import ( change_log_generate, @@ -40,6 +41,30 @@ def is_arm_sdk(package_name: str) -> bool: return package_name.startswith("azure-mgmt-") +def has_prior_ga_release(package_name: str) -> bool: + """Determine review eligibility from published PyPI history, retaining review when history is unavailable.""" + from pypi_tools.pypi import PyPIClient + + try: + releases = PyPIClient(force_pypi=True).project(package_name)["releases"] + for release, files in releases.items(): + # Yanked files still count as published; empty release entries do not. + if not isinstance(files, list): + raise ValueError(f"Invalid release file list for {release!r}: expected a list") + if not files: + continue + version = Version(release) + if version.base_version != "0.0.0" and not version.is_prerelease: + return True + return False + except Exception as e: + _LOGGER.warning( + f"Failed to determine prior GA releases for {package_name}: {e}; " + "keeping breaking-change reporting enabled." + ) + return True + + # CHANGELOG.md is embedded into the package long_description (see setup.py template), so an # unbounded changelog bloats the PyPI metadata. Trimming uses a high-water/low-water pattern: # trimming is *triggered* when the file exceeds CHANGELOG_SIZE_LIMIT_BYTES, but when it runs the @@ -264,10 +289,13 @@ def main( changelog_generation_start_time = time.time() md_output, last_version = get_changelog_content(package_path, package_result, enable_changelog, timeout=timeout) _LOGGER.info(f"changelog generation cost time: {int(time.time() - changelog_generation_start_time)} seconds") + has_breaking_change = "Breaking Changes" in md_output + if has_breaking_change and (package_result or output_json is not None): + has_breaking_change = has_prior_ga_release(package_name) if package_result: package_result["changelog"] = { "content": md_output, - "hasBreakingChange": "Breaking Changes" in md_output, + "hasBreakingChange": has_breaking_change, "breakingChangeItems": extract_breaking_change(md_output), } package_result["version"] = last_version @@ -278,7 +306,7 @@ def main( if output_json is not None: result = { "changes": md_output, - "hasBreakingChange": "Breaking Changes" in md_output, + "hasBreakingChange": has_breaking_change, } output_json = Path(output_json) output_json.parent.mkdir(parents=True, exist_ok=True) diff --git a/eng/tools/azure-sdk-tools/pyproject.toml b/eng/tools/azure-sdk-tools/pyproject.toml index 0846df8df1b7..4c28b330e884 100644 --- a/eng/tools/azure-sdk-tools/pyproject.toml +++ b/eng/tools/azure-sdk-tools/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "setuptools", "pyparsing", "certifi", - "cibuildwheel==2.23.3", + "cibuildwheel", "pkginfo", "build", "packaging", diff --git a/eng/tools/azure-sdk-tools/tests/test_1es_proxy_trust.py b/eng/tools/azure-sdk-tools/tests/test_1es_proxy_trust.py new file mode 100644 index 000000000000..7dadf7b17fe6 --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_1es_proxy_trust.py @@ -0,0 +1,444 @@ +import os +import ssl +import subprocess +import sys +import venv +from pathlib import Path +from types import SimpleNamespace + +import certifi +import pytest +import yaml +from cibuildwheel.oci_container import OCIContainerEngineConfig +from cibuildwheel.options import CommandLineArguments, Options + +REPO_ROOT = Path(__file__).resolve().parents[4] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from eng.scripts import run_with_1es_proxy_trust as proxy_trust + + +@pytest.fixture(params=["win32", "linux"]) +def proxy_ca_environment(request, tmp_path, monkeypatch): + roots = ssl.create_default_context(cafile=certifi.where()).get_ca_certs(binary_form=True) + baseline = tmp_path / "baseline.pem" + proxy_ca = tmp_path / "agent supplied proxy CA.pem" + baseline.write_text(ssl.DER_cert_to_PEM_cert(roots[0]).rstrip(), encoding="ascii") + proxy_ca.write_text(ssl.DER_cert_to_PEM_cert(roots[1]), encoding="ascii") + monkeypatch.setattr(sys, "platform", request.param) + if request.param == "linux": + # Keep Linux's case-sensitive proxy variables distinct when these tests run on Windows. + monkeypatch.setattr(os, "environ", dict(os.environ)) + for name in ("CIBW_CONTAINER_ENGINE", "CIBW_CONTAINER_ENGINE_LINUX", "https_proxy"): + monkeypatch.delenv(name, raising=False) + for name, value in { + "TF_BUILD": "True", + "BUILD_BUILDID": "not-used-to-discover-the-certificate", + "1ESNI_CONFIG_PATH": str(tmp_path), + "HTTPS_PROXY": "http://proxy.example.invalid:18080", + "SSL_CERT_FILE": str(baseline), + "NODE_EXTRA_CA_CERTS": str(proxy_ca), + "REQUESTS_CA_BUNDLE": "unchanged-requests-ca.pem", + "PIP_INDEX_URL": "https://example.invalid/cfs", + "UV_DEFAULT_INDEX": "https://example.invalid/cfs", + "UV_SYSTEM_CERTS": "1", + "CIBW_BUILD": "cp310* pp*", + "CIBW_SKIP": "*-musllinux*", + "CIBW_BUILD_FRONTEND": "build", + "CIBW_ARCHS_WINDOWS": "AMD64 ARM64", + "CIBW_ENVIRONMENT": 'CFLAGS="-DSTORAGE_TEST"', + }.items(): + monkeypatch.setenv(name, value) + real_temporary_directory = proxy_trust.TemporaryDirectory + monkeypatch.setattr( + proxy_trust, "TemporaryDirectory", lambda **kwargs: real_temporary_directory(dir=tmp_path, **kwargs) + ) + return baseline, proxy_ca, {roots[0], roots[1]} + + +@pytest.mark.parametrize("explicit_baseline", [False, True]) +def test_proxy_bundle(proxy_ca_environment, explicit_baseline, tmp_path, monkeypatch): + baseline, proxy_ca, expected_roots = proxy_ca_environment + if not explicit_baseline: + monkeypatch.delenv("SSL_CERT_FILE") + baseline = Path(certifi.where()) + expected_roots = set(ssl.create_default_context(cafile=str(baseline)).get_ca_certs(binary_form=True)) + before = os.environ.copy() + + assert proxy_trust.proxy_trust_required() + bundle = tmp_path / "combined.pem" + environment = proxy_trust.create_proxy_environment(bundle) + assert bundle.read_bytes() == baseline.read_bytes() + b"\n" + proxy_ca.read_bytes() + b"\n" + context = ssl.create_default_context(cafile=str(bundle)) + assert set(context.get_ca_certs(binary_form=True)) == expected_roots + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname + expected_environment = {**before, "SSL_CERT_FILE": str(bundle)} + if sys.platform == "linux": + expected_environment["CIBW_CONTAINER_ENGINE_LINUX"] = environment["CIBW_CONTAINER_ENGINE_LINUX"] + assert environment == expected_environment + assert os.environ == before + + +@pytest.mark.parametrize( + "platform,name,value", + [ + ("darwin", None, None), + ("freebsd", None, None), + (None, "TF_BUILD", None), + (None, "TF_BUILD", "False"), + (None, "1ESNI_CONFIG_PATH", None), + (None, "HTTPS_PROXY", None), + (None, "HTTPS_PROXY", ""), + ], +) +def test_proxy_trust_inactive(proxy_ca_environment, platform, name, value, monkeypatch): + if platform: + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "unreadable-and-not-trusted-outside-1es.pem") + if name: + if value is None: + monkeypatch.delenv(name) + else: + monkeypatch.setenv(name, value) + before = os.environ.copy() + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "sdk_build"]) + monkeypatch.setattr( + proxy_trust, + "TemporaryDirectory", + lambda **kwargs: pytest.fail("Inactive trust must not create temporary files."), + ) + + def run(command, **kwargs): + assert command == ["sdk_build"] + assert kwargs == {"env": None, "check": False} + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + assert not proxy_trust.proxy_trust_required() + assert proxy_trust.main() == 0 + assert os.environ == before + + +@pytest.mark.parametrize("proxy_ca_environment", ["linux"], indirect=True) +@pytest.mark.parametrize( + "settings", + [ + {}, + {"CIBW_CONTAINER_ENGINE": "podman; create-args: --network=host; disable-host-mount: true"}, + { + "CIBW_CONTAINER_ENGINE": "podman; create_args: --network=host", + "CIBW_CONTAINER_ENGINE_LINUX": "docker; create_args: '--label=purpose=proxy test; scoped'", + }, + ], +) +def test_linux_container_proxy_trust(proxy_ca_environment, settings, tmp_path, monkeypatch): + for name, value in { + **settings, + "HTTP_PROXY": "http://proxy.example.invalid:18080", + "NO_PROXY": "localhost,.example.invalid", + "https_proxy": "http://proxy.example.invalid:18080", + "http_proxy": "http://proxy.example.invalid:18080", + "no_proxy": "localhost,.example.invalid", + "CIBW_ENVIRONMENT_PASS_LINUX": "PIP_INDEX_URL MSRUSTUP_ACCESS_TOKEN", + "MSRUSTUP_ACCESS_TOKEN": "test-only-token", + }.items(): + monkeypatch.setenv(name, value) + before = os.environ.copy() + original_engine = OCIContainerEngineConfig.from_config_string( + before.get("CIBW_CONTAINER_ENGINE_LINUX", before.get("CIBW_CONTAINER_ENGINE", "docker")) + ) + + bundle = tmp_path / "CA bundle with spaces.pem" + environment = proxy_trust.create_proxy_environment(bundle) + engine = OCIContainerEngineConfig.from_config_string(environment["CIBW_CONTAINER_ENGINE_LINUX"]) + assert engine.name == original_engine.name + assert engine.disable_host_mount == original_engine.disable_host_mount + assert engine.create_args == ( + *original_engine.create_args, + f"--volume={bundle}:{bundle}:ro", + "--env=SSL_CERT_FILE", + "--env=HTTPS_PROXY", + "--env=HTTP_PROXY", + "--env=NO_PROXY", + "--env=https_proxy", + "--env=http_proxy", + "--env=no_proxy", + ) + assert environment == { + **before, + "SSL_CERT_FILE": str(bundle), + "CIBW_CONTAINER_ENGINE_LINUX": environment["CIBW_CONTAINER_ENGINE_LINUX"], + } + assert "test-only-token" not in environment["CIBW_CONTAINER_ENGINE_LINUX"] + assert os.environ == before + + +@pytest.mark.parametrize("proxy_ca_environment", ["linux"], indirect=True) +def test_linux_container_preserves_package_environment(proxy_ca_environment, tmp_path, monkeypatch): + config = tmp_path / "pyproject.toml" + config.write_text( + '[tool.cibuildwheel]\nenvironment = {CFLAGS = "-DSTORAGE_TEST"}\n', + encoding="ascii", + ) + monkeypatch.delenv("CIBW_ENVIRONMENT") + monkeypatch.setenv("CIBW_ENVIRONMENT_PASS_LINUX", "PIP_INDEX_URL MSRUSTUP_ACCESS_TOKEN") + monkeypatch.setenv("MSRUSTUP_ACCESS_TOKEN", "test-only-token") + monkeypatch.setenv("CIBW_ARCHS_LINUX", "x86_64") + arguments = CommandLineArguments.defaults() + arguments.package_dir = tmp_path + arguments.config_file = str(config) + + environment = proxy_trust.create_proxy_environment(tmp_path / "combined.pem") + options = Options(platform="linux", command_line_arguments=arguments, env=environment) + build_options = options.build_options("cp310-manylinux_x86_64") + container_environment = build_options.environment.as_dictionary( + prev_environment={"SSL_CERT_FILE": environment["SSL_CERT_FILE"]} + ) + assert container_environment["SSL_CERT_FILE"] == environment["SSL_CERT_FILE"] + assert container_environment["CFLAGS"] == "-DSTORAGE_TEST" + assert container_environment["PIP_INDEX_URL"] == environment["PIP_INDEX_URL"] + assert container_environment["MSRUSTUP_ACCESS_TOKEN"] == "test-only-token" + assert "--env=SSL_CERT_FILE" in build_options.container_engine.create_args + + +@pytest.mark.parametrize("proxy_ca_environment", ["linux"], indirect=True) +def test_linux_lowercase_proxy(proxy_ca_environment, tmp_path, monkeypatch): + monkeypatch.delenv("HTTPS_PROXY") + monkeypatch.setenv("https_proxy", "http://proxy.example.invalid:18080") + assert proxy_trust.proxy_trust_required() + environment = proxy_trust.create_proxy_environment(tmp_path / "combined.pem") + assert Path(environment["SSL_CERT_FILE"]).is_file() + engine = OCIContainerEngineConfig.from_config_string(environment["CIBW_CONTAINER_ENGINE_LINUX"]) + assert "--env=https_proxy" in engine.create_args + assert "--env=HTTPS_PROXY" not in engine.create_args + + +@pytest.mark.parametrize("proxy_ca_environment", ["linux"], indirect=True) +def test_invalid_linux_container_engine_cleans_up(proxy_ca_environment, tmp_path, monkeypatch): + monkeypatch.setenv("CIBW_CONTAINER_ENGINE_LINUX", "not-a-container-engine") + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "sdk_build"]) + monkeypatch.setattr( + proxy_trust.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("Invalid configuration must not launch a child."), + ) + before = os.environ.copy() + with pytest.raises(ValueError, match="unknown container engine"): + proxy_trust.main() + assert not list(tmp_path.glob("pipeline-proxy-ca-*")) + assert os.environ == before + + +@pytest.mark.parametrize("value", [None, ""]) +def test_missing_proxy_ca_path(proxy_ca_environment, value, tmp_path, monkeypatch): + if value is None: + monkeypatch.delenv("NODE_EXTRA_CA_CERTS") + else: + monkeypatch.setenv("NODE_EXTRA_CA_CERTS", value) + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "sdk_build"]) + monkeypatch.setattr( + proxy_trust.subprocess, "run", lambda *args, **kwargs: pytest.fail("Missing proxy CA must not launch a child.") + ) + before = os.environ.copy() + with pytest.raises(RuntimeError, match="CA path supplied in NODE_EXTRA_CA_CERTS"): + proxy_trust.main() + assert not list(tmp_path.glob("pipeline-proxy-ca-*")) + assert os.environ == before + + +@pytest.mark.parametrize("source", ["baseline", "proxy"]) +@pytest.mark.parametrize("state", ["missing", "empty", "invalid"]) +def test_invalid_certificates(proxy_ca_environment, source, state, tmp_path, monkeypatch): + baseline, proxy_ca, _ = proxy_ca_environment + certificate = baseline if source == "baseline" else proxy_ca + if state == "missing": + certificate.unlink() + expected_error = FileNotFoundError + else: + certificate.write_text("" if state == "empty" else "not a certificate", encoding="ascii") + expected_error = ssl.SSLError + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "sdk_build"]) + monkeypatch.setattr( + proxy_trust.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("Invalid certificates must not launch a child."), + ) + before = os.environ.copy() + with pytest.raises(expected_error): + proxy_trust.main() + assert not list(tmp_path.glob("pipeline-proxy-ca-*")) + assert os.environ == before + + +@pytest.mark.parametrize("returncode", [0, 7]) +def test_child_command_and_exit_code(proxy_ca_environment, returncode, monkeypatch): + command = ["sdk_build", "--service=storage", "a package with spaces", "--inactive"] + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", *command]) + before = os.environ.copy() + bundle = None + + def run(command_args, **kwargs): + nonlocal bundle + bundle = Path(kwargs["env"]["SSL_CERT_FILE"]) + assert bundle.is_file() + assert command_args == command + expected_environment = {**before, "SSL_CERT_FILE": str(bundle)} + if sys.platform == "linux": + expected_environment["CIBW_CONTAINER_ENGINE_LINUX"] = kwargs["env"]["CIBW_CONTAINER_ENGINE_LINUX"] + assert kwargs == {"env": expected_environment, "check": False} + assert os.environ == before + return SimpleNamespace(returncode=returncode) + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + assert proxy_trust.main() == returncode + assert not bundle.parent.exists() + assert os.environ == before + + +def test_child_launch_failure_cleans_up(proxy_ca_environment, monkeypatch): + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "missing-command"]) + before = os.environ.copy() + bundle = None + + def run(*args, **kwargs): + nonlocal bundle + bundle = Path(kwargs["env"]["SSL_CERT_FILE"]) + assert bundle.is_file() + raise FileNotFoundError("missing-command") + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + with pytest.raises(FileNotFoundError, match="missing-command"): + proxy_trust.main() + assert not bundle.parent.exists() + assert os.environ == before + + +def test_child_process_uses_bundle(proxy_ca_environment, monkeypatch, capfd): + before = os.environ.copy() + script = ( + "import os, ssl; from pathlib import Path; " + "bundle = os.environ['SSL_CERT_FILE']; " + "context = ssl.create_default_context(cafile=bundle); " + "assert context.verify_mode == ssl.CERT_REQUIRED and context.check_hostname; " + "assert len(context.get_ca_certs()) == 2; " + "print(bundle); raise SystemExit(7)" + ) + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "python", "-c", script]) + assert proxy_trust.main() == 7 + bundle = Path(capfd.readouterr().out.strip().splitlines()[-1]) + assert not bundle.parent.exists() + assert os.environ == before + + +def test_inactive_child_inherits_environment(monkeypatch): + monkeypatch.setenv("TF_BUILD", "False") + monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "unreadable-and-not-trusted-outside-1es.pem") + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", "sdk_build", "--inactive"]) + before = os.environ.copy() + + def run(command, **kwargs): + assert command == ["sdk_build", "--inactive"] + assert kwargs == {"env": None, "check": False} + assert os.environ == before + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + assert proxy_trust.main() == 0 + + +@pytest.mark.parametrize("command", ["python", "python.exe"]) +@pytest.mark.parametrize("active", [False, True]) +def test_python_alias_uses_wrapper_interpreter(proxy_ca_environment, command, active, tmp_path, monkeypatch): + if not active: + monkeypatch.setenv("TF_BUILD", "False") + executable = str(tmp_path / "venv with spaces" / "python.exe") + arguments = ["script with spaces.py", "--service=storage"] + monkeypatch.setattr(sys, "executable", executable) + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", command, *arguments]) + + def run(command_args, **kwargs): + assert command_args == [executable, *arguments] + assert kwargs["check"] is False + if active: + assert Path(kwargs["env"]["SSL_CERT_FILE"]).is_file() + else: + assert kwargs["env"] is None + return SimpleNamespace(returncode=7) + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + assert proxy_trust.main() == 7 + + +def test_bare_python_command_preserves_virtualenv(tmp_path, monkeypatch): + environment = tmp_path / "venv with spaces" + venv.EnvBuilder(with_pip=False).create(environment) + python = environment / ("Scripts" if os.name == "nt" else "bin") / ("python.exe" if os.name == "nt" else "python") + site_packages = next(environment.rglob("site-packages")) + # Let the wrapper import the already-installed certifi without downloading packages. + (site_packages / "test-certifi.pth").write_text( + str(Path(certifi.__file__).resolve().parent.parent) + "\n", encoding="utf-8" + ) + (site_packages / "venv_only_dependency.py").write_text("VALUE = 123\n", encoding="ascii") + script = ( + "import sys, venv_only_dependency; " + "assert venv_only_dependency.VALUE == 123; " + "print(sys.prefix); raise SystemExit(7)" + ) + monkeypatch.setenv("TF_BUILD", "False") + monkeypatch.setenv("PATH", str(python.parent) + os.pathsep + os.environ["PATH"]) + + result = subprocess.run( + [str(python), str(Path(proxy_trust.__file__).resolve()), "python", "-c", script], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 7, result.stderr + assert Path(result.stdout.strip()) == environment + + +@pytest.mark.parametrize("command", ["python3", "sdk_build", sys.executable, os.path.join("custom", "python.exe")]) +def test_other_commands_are_not_rewritten(command, monkeypatch): + monkeypatch.setenv("TF_BUILD", "False") + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py", command, "--version"]) + + def run(command_args, **kwargs): + assert command_args == [command, "--version"] + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(proxy_trust.subprocess, "run", run) + assert proxy_trust.main() == 0 + + +def test_missing_command(monkeypatch): + monkeypatch.setattr(sys, "argv", ["run_with_1es_proxy_trust.py"]) + with pytest.raises(SystemExit) as error: + proxy_trust.main() + assert error.value.code == 2 + + +@pytest.mark.parametrize("federated", [False, True]) +def test_pipeline_commands_use_wrapper(federated): + steps_dir = REPO_ROOT / "eng" / "pipelines" / "templates" / "steps" + artifact_steps = yaml.safe_load((steps_dir / "build-package-artifacts.yml").read_text())["steps"] + build_step = next(step for step in artifact_steps if step.get("displayName") == "Generate Packages") + assert "python eng/scripts/run_with_1es_proxy_trust.py sdk_build " in build_step["pwsh"] + assert "exit $LASTEXITCODE" in build_step["pwsh"] + + test_steps = yaml.safe_load((steps_dir / "build-test.yml").read_text())["steps"] + branches = [step for step in test_steps if any("UseFederatedAuth" in key or "${{ else }}" in key for key in step)] + assert len(branches) == 4 + commands = [] + for branch in branches: + key = next(iter(branch)) + if ("UseFederatedAuth" in key) == federated: + for step in branch[key]: + if federated: + commands.append(step["inputs"]["Inline"]) + else: + commands.append(step["pwsh"]) + assert len(commands) == 2 + for command in commands: + assert "python eng/scripts/run_with_1es_proxy_trust.py python eng/scripts/dispatch_checks.py" in command diff --git a/eng/tools/azure-sdk-tools/tests/test_build_interactions.py b/eng/tools/azure-sdk-tools/tests/test_build_interactions.py index 7bc1546390e8..05f4e1c90f7c 100644 --- a/eng/tools/azure-sdk-tools/tests/test_build_interactions.py +++ b/eng/tools/azure-sdk-tools/tests/test_build_interactions.py @@ -1,6 +1,13 @@ import os, tempfile, shutil +import subprocess +import sys +from subprocess import CalledProcessError +from types import SimpleNamespace -from ci_tools.build import discover_targeted_packages, build_packages, build +import pytest + +import ci_tools.build as build_module +from ci_tools.build import discover_targeted_packages, build_packages, build, create_package repo_root = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") integration_folder = os.path.join(os.path.dirname(__file__), "integration") @@ -38,3 +45,177 @@ def test_venv_helpers_importable(): from ci_tools.functions import get_venv_call as f_get_venv_call assert f_get_venv_call is get_venv_call + + +@pytest.fixture +def inherited_trust(tmp_path, monkeypatch): + monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "pipeline-ca-bundle.pem")) + monkeypatch.setenv("NODE_EXTRA_CA_CERTS", str(tmp_path / "unused-node-ca.pem")) + + +@pytest.mark.parametrize("is_pyproject", [False, True]) +@pytest.mark.parametrize("ext_modules,uses_cibuildwheel", [(True, False), (False, True)]) +@pytest.mark.parametrize("fail", [False, True]) +@pytest.mark.parametrize("enable_sdist", [False, True]) +def test_create_compiled_package_inherits_environment( + inherited_trust, is_pyproject, ext_modules, uses_cibuildwheel, fail, enable_sdist, tmp_path, monkeypatch +): + package = SimpleNamespace( + is_pyproject=is_pyproject, + ext_modules=ext_modules, + uses_cibuildwheel=uses_cibuildwheel, + folder=str(tmp_path), + ) + monkeypatch.setattr(build_module.ParsedSetup, "from_path", lambda _: package) + dist = str(tmp_path / "dist") + monkeypatch.setattr(build_module, "get_artifact_directory", lambda _: dist) + before = os.environ.copy() + calls = [] + + def run_build(command, **kwargs): + calls.append(command) + assert kwargs["cwd"] == package.folder + assert kwargs["check"] is True + assert "env" not in kwargs + assert os.environ == before + if command[2] == "cibuildwheel" and fail: + raise CalledProcessError(1, command) + + monkeypatch.setattr(build_module, "run_logged", run_build) + if fail: + with pytest.raises(CalledProcessError): + build_module.create_package(package.folder, dist, enable_sdist=enable_sdist) + else: + build_module.create_package(package.folder, dist, enable_sdist=enable_sdist) + + assert calls[0] == [sys.executable, "-m", "cibuildwheel", "--output-dir", dist] + assert len(calls) == (1 if fail or not enable_sdist else 2) + if not fail and enable_sdist: + assert calls[1] == ( + [sys.executable, "-m", "build", "-s", "-o", dist] + if is_pyproject + else [sys.executable, "setup.py", "sdist", "-d", dist] + ) + assert os.environ == before + + +@pytest.mark.parametrize("is_pyproject", [False, True]) +@pytest.mark.parametrize("ext_modules,enable_wheel", [(False, True), (True, False)]) +def test_non_cibuildwheel_builds_inherit_environment( + inherited_trust, is_pyproject, ext_modules, enable_wheel, tmp_path, monkeypatch +): + package = SimpleNamespace( + is_pyproject=is_pyproject, + ext_modules=ext_modules, + uses_cibuildwheel=False, + folder=str(tmp_path), + requires=[], + ) + monkeypatch.setattr(build_module.ParsedSetup, "from_path", lambda _: package) + monkeypatch.setattr(build_module, "get_artifact_directory", lambda _: str(tmp_path / "dist")) + monkeypatch.setattr(build_module, "get_pip_list_output", lambda _: {}) + before = os.environ.copy() + calls = [] + + def run_build(command, **kwargs): + calls.append(command) + assert "cibuildwheel" not in command + assert "env" not in kwargs + assert os.environ == before + + monkeypatch.setattr(build_module, "run_logged", run_build) + build_module.create_package(package.folder, str(tmp_path / "dist"), enable_wheel=enable_wheel) + assert len(calls) == (2 if enable_wheel or (is_pyproject and not ext_modules) else 1) + assert os.environ == before + + +@pytest.fixture +def build_commands(monkeypatch): + calls = [] + monkeypatch.setattr("ci_tools.build.run_logged", lambda command, **kwargs: calls.append((command, kwargs))) + monkeypatch.setattr("ci_tools.build.get_pip_list_output", lambda executable: {}) + return calls + + +@pytest.mark.parametrize( + "scenario,cibuildwheel_config,compiled", + [ + ("pyproject_project_def", "", False), + ("pyproject_project_def_with_extension", "", True), + ("setup_py_project_def", "", False), + ("pyproject_project_def", '\n[tool.cibuildwheel]\nbuild = "cp310-*"\n', True), + ("setup_py_project_def", '\n[tool.cibuildwheel]\nbuild = "cp310-*"\n', True), + ], +) +@pytest.mark.parametrize("enable_wheel,enable_sdist", [(True, False), (True, True), (False, True)]) +def test_create_package_build_routing( + tmp_path, build_commands, scenario, cibuildwheel_config, compiled, enable_wheel, enable_sdist +): + package = tmp_path / "package" + shutil.copytree(os.path.join(integration_folder, "scenarios", scenario), package) + if cibuildwheel_config: + pyproject = package / "pyproject.toml" + contents = pyproject.read_text() if pyproject.exists() else "" + contents = contents.replace("setuptools.build_meta", "maturin") + pyproject.write_text(contents + cibuildwheel_config) + + destination = tmp_path / "dist" + create_package(str(package), str(destination), enable_wheel=enable_wheel, enable_sdist=enable_sdist) + + commands = [command for command, _ in build_commands] + cibuildwheel_command = [sys.executable, "-m", "cibuildwheel", "--output-dir", str(destination)] + assert (cibuildwheel_command in commands) is (compiled and enable_wheel) + assert all(options["cwd"] == str(package) for _, options in build_commands) + assert all(options["check"] for command, options in build_commands if "pip" not in command) + if compiled and scenario.startswith("pyproject"): + assert commands == ([cibuildwheel_command] if enable_wheel else []) + ( + [[sys.executable, "-m", "build", "-s", "-o", str(destination)]] if enable_sdist else [] + ) + elif not compiled and scenario.startswith("pyproject"): + build_flags = "-nsw" if enable_wheel and enable_sdist else "-nw" if enable_wheel else "-ns" + assert commands[-1] == [ + sys.executable, + "-m", + "build", + build_flags, + "-o", + str(destination), + ] + else: + expected_commands = [] + if enable_wheel: + expected_commands.append( + cibuildwheel_command + if compiled + else [sys.executable, "setup.py", "bdist_wheel", "-d", str(destination)] + ) + if enable_sdist: + expected_commands.append([sys.executable, "setup.py", "sdist", "-d", str(destination)]) + assert commands == expected_commands + + +def test_sdist_only_does_not_invoke_cibuildwheel(tmp_path, build_commands): + package = tmp_path / "package" + shutil.copytree(os.path.join(integration_folder, "scenarios", "pyproject_project_def"), package) + pyproject = package / "pyproject.toml" + contents = pyproject.read_text().replace("setuptools.build_meta", "maturin") + contents = contents.replace('requires = ["setuptools>=61.0.0", "wheel"]', 'requires = ["maturin"]') + pyproject.write_text(contents + '\n[tool.cibuildwheel]\nbuild = "cp310-*"\n') + + destination = tmp_path / "dist" + create_package(str(package), str(destination), enable_wheel=False) + + assert [command for command, _ in build_commands] == [[sys.executable, "-m", "build", "-s", "-o", str(destination)]] + assert build_commands[0][1]["check"] is True + + +def test_cibuildwheel_failure_propagates(tmp_path, monkeypatch): + package = tmp_path / "package" + shutil.copytree(os.path.join(integration_folder, "scenarios", "pyproject_project_def_with_extension"), package) + + def fail_build(command, **kwargs): + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr("ci_tools.build.run_logged", fail_build) + with pytest.raises(subprocess.CalledProcessError): + create_package(str(package), str(tmp_path / "dist")) diff --git a/eng/tools/azure-sdk-tools/tests/test_dispatch_checks.py b/eng/tools/azure-sdk-tools/tests/test_dispatch_checks.py index c286d38c2e68..47da78ce1399 100644 --- a/eng/tools/azure-sdk-tools/tests/test_dispatch_checks.py +++ b/eng/tools/azure-sdk-tools/tests/test_dispatch_checks.py @@ -1,7 +1,10 @@ +import asyncio import os import sys from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) TOOLS_ROOT = os.path.join(REPO_ROOT, "eng", "tools", "azure-sdk-tools") @@ -10,7 +13,7 @@ if REPO_ROOT not in sys.path: sys.path.insert(0, REPO_ROOT) -from eng.scripts.dispatch_checks import get_check_dest_dir +from eng.scripts.dispatch_checks import CheckResult, get_check_dest_dir, run_all_checks def test_apistub_dest_dir_uses_package_subdirectory(): @@ -45,3 +48,28 @@ def test_empty_dest_dir_is_unchanged(): assert result is None parsed_setup.assert_not_called() + + +@pytest.mark.parametrize("apistub_enabled", [False, True, None]) +def test_dispatch_requires_apistub(tmp_path, monkeypatch, apistub_enabled): + package_dir = str(tmp_path) + config = "[tool.azure-sdk-build]\npylint = false\n" + if apistub_enabled is not None: + config += f"apistub = {str(apistub_enabled).lower()}\n" + (tmp_path / "pyproject.toml").write_text(config) + for check in ("APISTUB", "PYLINT", "CI_ENABLED"): + monkeypatch.delenv(f"{tmp_path.name.upper().replace('-', '_')}_{check}", raising=False) + result = CheckResult(package_dir, "apistub", 0, 0.0, "", "") + + with patch("eng.scripts.dispatch_checks.in_ci", return_value=False), patch( + "eng.scripts.dispatch_checks.run_check", new_callable=AsyncMock, return_value=result + ) as run_check: + invocation = run_all_checks([package_dir], ["pylint", "apistub"], 1, str(tmp_path), None, "") + if apistub_enabled is False: + with pytest.raises(ValueError, match="apistub is required"): + asyncio.run(invocation) + run_check.assert_not_called() + else: + assert asyncio.run(invocation) == 0 + run_check.assert_awaited_once() + assert run_check.call_args.args[2] == "apistub" diff --git a/eng/tools/azure-sdk-tools/tests/test_logging_config.py b/eng/tools/azure-sdk-tools/tests/test_logging_config.py index c58af719598a..a360f1e0e998 100644 --- a/eng/tools/azure-sdk-tools/tests/test_logging_config.py +++ b/eng/tools/azure-sdk-tools/tests/test_logging_config.py @@ -1,9 +1,11 @@ import logging -from ci_tools.logging import configure_logging, logger +from ci_tools.logging import configure_logging, logger, run_logged from unittest.mock import patch import pytest import argparse import os +import sys +import subprocess @pytest.mark.parametrize( @@ -24,3 +26,83 @@ def test_configure_logging_various_levels(mock_basic_config, cli_args, level_env mock_basic_config.assert_called_with( level=expected_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", force=True ) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_run_logged_inherits_environment(stream, tmp_path, monkeypatch, capfd): + monkeypatch.setenv("CI_TOOLS_LOGGED_ENV_TEST", "parent") + before = os.environ.copy() + result = run_logged( + [sys.executable, "-c", "import os; print(os.environ['CI_TOOLS_LOGGED_ENV_TEST'])"], + cwd=str(tmp_path), + check=True, + should_stream_to_console=stream, + ) + output = capfd.readouterr().out if stream else result.stdout + assert output.strip() == "parent" + assert os.environ == before + + +@pytest.mark.parametrize("stream", [False, True]) +def test_run_logged_uses_explicit_environment(stream, tmp_path, monkeypatch, capfd): + monkeypatch.setenv("CI_TOOLS_LOGGED_ENV_TEST", "parent") + monkeypatch.setenv("CI_TOOLS_LOGGED_REMOVED_TEST", "parent-only") + before = os.environ.copy() + environment = {**before, "CI_TOOLS_LOGGED_ENV_TEST": "child"} + environment.pop("CI_TOOLS_LOGGED_REMOVED_TEST") + result = run_logged( + [ + sys.executable, + "-c", + "import os; print(os.environ['CI_TOOLS_LOGGED_ENV_TEST']); " + "assert 'CI_TOOLS_LOGGED_REMOVED_TEST' not in os.environ", + ], + cwd=str(tmp_path), + check=True, + should_stream_to_console=stream, + env=environment, + ) + output = capfd.readouterr().out if stream else result.stdout + assert output.strip() == "child" + assert os.environ == before + assert environment["CI_TOOLS_LOGGED_ENV_TEST"] == "child" + assert "CI_TOOLS_LOGGED_REMOVED_TEST" not in environment + + +@pytest.mark.parametrize("stream", [False, True]) +def test_run_logged_failure_preserved(stream, tmp_path, caplog): + with pytest.raises(subprocess.CalledProcessError) as error: + run_logged( + [sys.executable, "-c", "print('build failed'); raise SystemExit(7)"], + cwd=str(tmp_path), + check=True, + should_stream_to_console=stream, + ) + assert error.value.returncode == 7 + assert "Command failed:" in caplog.text + if not stream: + assert "build failed" in caplog.text + + +@pytest.mark.parametrize("returncode", [0, 7]) +def test_run_logged_decodes_native_build_output(returncode, tmp_path, caplog, monkeypatch): + monkeypatch.setattr("locale.getencoding", lambda: "cp1252", raising=False) + command = [ + sys.executable, + "-c", + "import sys; sys.stdout.buffer.write(b'build \\xe2\\x9c\\x8d\\n'); " + "sys.stderr.buffer.write(b'diagnostic \\xff\\n'); " + f"raise SystemExit({returncode})", + ] + if returncode: + with pytest.raises(subprocess.CalledProcessError) as error: + run_logged(command, cwd=str(tmp_path), check=True, should_stream_to_console=False) + assert error.value.returncode == returncode + output = error.value.stdout + assert output.strip() in caplog.text + else: + result = run_logged(command, cwd=str(tmp_path), check=True, should_stream_to_console=False) + assert result.returncode == 0 + output = result.stdout + assert "build \u270d\n" in output + assert "diagnostic \\xff\n" in output diff --git a/eng/tools/azure-sdk-tools/tests/test_native_build_pipeline.py b/eng/tools/azure-sdk-tools/tests/test_native_build_pipeline.py new file mode 100644 index 000000000000..b83ec25620c1 --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_native_build_pipeline.py @@ -0,0 +1,349 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[4] +TEMPLATES = REPO_ROOT / "eng" / "pipelines" / "templates" +RUST_GATE = "${{ if parameters.InstallMsRustToolchain }}" +RUST_PARAMETERS = ( + "InstallMsRustToolchain", + "MsRustWorkingDirectory", + "MsRustToolchainFeed", + "MsRustAdditionalTargets", +) + + +def load_template(relative_path): + return yaml.safe_load((TEMPLATES / relative_path).read_text()) + + +def run_powershell(script, **environment): + powershell = shutil.which("pwsh") + if not powershell: + pytest.skip("PowerShell is required to execute pipeline scripts") + return subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", script], + env={**os.environ, **environment}, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +def test_analysis_checkout_has_history_for_api_comparison(): + jobs = load_template("jobs/ci.yml") + analyze = next(job for job in jobs["jobs"] if job.get("job") == "Analyze") + assert analyze["steps"][0] == { + "checkout": "self", + "${{ if eq(variables['Build.Reason'], 'PullRequest') }}": {"fetchDepth": 0, "fetchTags": False}, + } + + +def test_rust_support_is_disabled_by_default_and_forwarded(): + archetype = load_template("stages/archetype-sdk-client.yml") + jobs = load_template("jobs/ci.yml") + build = load_template("steps/build-package-artifacts.yml") + for template in (archetype, jobs, build): + defaults = { + parameter["name"]: parameter["default"] + for parameter in template["parameters"] + if parameter["name"] in RUST_PARAMETERS + } + assert defaults["InstallMsRustToolchain"] is False + assert all(defaults[name] == "" for name in RUST_PARAMETERS[1:]) + + build_stage = next(stage for stage in archetype["extends"]["parameters"]["stages"] if stage.get("stage") == "Build") + forwarded = build_stage["jobs"][0]["parameters"] + assert all(forwarded[name] == "${{ parameters." + name + " }}" for name in RUST_PARAMETERS) + + job_defaults = {parameter["name"]: parameter.get("default") for parameter in jobs["parameters"]} + assert job_defaults["BuildTimeoutInMinutes"] == 90 + timeout_gate = "${{ if and(parameters.InstallMsRustToolchain, lt(parameters.BuildTimeoutInMinutes, 240)) }}" + for job in jobs["jobs"]: + if job.get("job") not in ("Build_Linux", "Build_Windows", "Build_MacOS"): + continue + assert job[timeout_gate]["timeoutInMinutes"] == 240 + assert job["${{ else }}"]["timeoutInMinutes"] == "${{ parameters.BuildTimeoutInMinutes }}" + forwarded = job["steps"][0]["parameters"] + assert all(forwarded[name] == "${{ parameters." + name + " }}" for name in RUST_PARAMETERS) + + +def test_rust_credentials_and_target_setup_are_opt_in(): + build = load_template("steps/build-package-artifacts.yml") + install = next(step[RUST_GATE][0] for step in build["steps"] if RUST_GATE in step) + assert install["template"].endswith("install-msrust-toolchain.yml") + assert install["parameters"]["Condition"] == "and(succeeded(), eq(variables['ENABLE_EXTENSION_BUILD'], 'true'))" + assert install["parameters"]["ToolchainFeed"] == "${{ parameters.MsRustToolchainFeed }}" + assert install["parameters"]["${{ if eq(parameters.ArtifactSuffix, 'windows') }}"] == { + "AdditionalTargets": "${{ parameters.MsRustAdditionalTargets }}" + } + + generate = next(step for step in build["steps"] if step.get("displayName") == "Generate Packages") + assert generate[RUST_GATE]["timeoutInMinutes"] == 210 + assert generate["${{ else }}"]["timeoutInMinutes"] == 80 + assert generate["env"]["LOGLEVEL"] == "$(SDK_BUILD_LOGLEVEL)" + assert generate["env"][RUST_GATE]["MSRUSTUP_ACCESS_TOKEN"] == "$(System.AccessToken)" + assert "MSRUSTUP_ACCESS_TOKEN" not in generate["env"] + + installer = load_template("steps/install-msrust-toolchain.yml") + assert all(step["condition"] == "${{ parameters.Condition }}" for step in installer["steps"]) + cargo_auth = next(step for step in installer["steps"] if step.get("task") == "CargoAuthenticate@0") + assert cargo_auth["env"] == {"SYSTEM_ACCESSTOKEN": "$(VSS_NUGET_ACCESSTOKEN)"} + + provision = next( + step for step in build["steps"] if step.get("displayName") == "Pre-provision CPython for cibuildwheel" + ) + assert provision["env"]["INSTALL_MS_RUST_TOOLCHAIN"] == "${{ format('{0}', parameters.InstallMsRustToolchain) }}" + assert "variable=AZURESDK_PYTHON_NUGET_FEED]" in provision["pwsh"] + assert "variable=AZURESDK_PYTHON_ARM64_LIBS]true" in provision["pwsh"] + assert "variable=PYTHON_ARM64_LIB_DIR]" not in provision["pwsh"] + + +@pytest.mark.parametrize("rust", ["true", "false"]) +def test_windows_provisioning_configuration(tmp_path, rust): + build = load_template("steps/build-package-artifacts.yml") + provision = next( + step for step in build["steps"] if step.get("displayName") == "Pre-provision CPython for cibuildwheel" + ) + assert provision["condition"] == ( + "and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['ENABLE_EXTENSION_BUILD'], 'true'))" + ) + script = provision["pwsh"].replace("$(Agent.TempDirectory)", str(tmp_path)) + result = run_powershell(script, INSTALL_MS_RUST_TOOLCHAIN=rust) + assert result.returncode == 0, result.stderr + assert "variable=CIBW_CACHE_PATH]" in result.stdout + assert ( + "variable=AZURESDK_PYTHON_NUGET_FEED]" + "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" + ) in result.stdout + assert ("variable=AZURESDK_PYTHON_ARM64_LIBS]true" in result.stdout) is (rust == "true") + assert "variable=PYTHON_ARM64_LIB_DIR]" not in result.stdout + + +def test_dev_requirements_share_selected_target_provisioning(tmp_path): + steps = [step for step in load_template("jobs/live.tests.yml")["jobs"][0]["steps"] if isinstance(step, dict)] + provision = next(step for step in steps if step.get("displayName") == "Pre-provision CPython for cibuildwheel") + assert provision["condition"] == ( + "and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true'))" + ) + configure = next( + step for step in steps if step.get("displayName") == "Configure cibuildwheel for dev requirement builds" + ) + assert "variable=CIBW_ARCHS]native" in configure["pwsh"] + assert "nuget install" not in provision["pwsh"] + assert "3.10.11" not in provision["pwsh"] + result = run_powershell(provision["pwsh"].replace("$(Agent.TempDirectory)", str(tmp_path))) + assert result.returncode == 0, result.stderr + assert "variable=CIBW_CACHE_PATH]" in result.stdout + assert "variable=AZURESDK_PYTHON_NUGET_FEED]" in result.stdout + assert "variable=AZURESDK_PYTHON_ARM64_LIBS]" not in result.stdout + + +def test_live_test_rust_support_is_opt_in_and_forwarded(): + archetype = load_template("stages/archetype-sdk-tests.yml") + live = load_template("jobs/live.tests.yml") + parameters = RUST_PARAMETERS[:3] + for template in (archetype, live): + defaults = {parameter["name"]: parameter.get("default") for parameter in template["parameters"]} + assert defaults["InstallMsRustToolchain"] is False + assert all(defaults[name] == "" for name in parameters[1:]) + + stages = archetype["extends"]["parameters"]["stages"][0] + clouds = next(iter(stages.values()))[0] + cloud_gate = next(iter(clouds.values()))[0] + supported_gate = next(iter(cloud_gate.values()))[0] + stage = next(iter(supported_gate.values()))[0] + matrix_types = stage["jobs"][0] + matrix_gate = next(iter(matrix_types.values()))[0] + matrix = next(iter(matrix_gate.values()))[0] + forwarded = matrix["parameters"]["AdditionalParameters"] + assert all(forwarded[name] == "${{ parameters." + name + " }}" for name in parameters) + + steps = live["jobs"][0]["steps"] + install = next(step[RUST_GATE][0] for step in steps if isinstance(step, dict) and RUST_GATE in step) + assert install["template"].endswith("install-msrust-toolchain.yml") + assert install["parameters"] == { + "ToolchainFeed": "${{ parameters.MsRustToolchainFeed }}", + "WorkingDirectory": "${{ parameters.MsRustWorkingDirectory }}", + "Condition": "and(succeeded(), eq(variables['CIBUILDWHEEL_DEV_REQ'], 'true'))", + } + test = next( + step for step in steps if isinstance(step, dict) and step.get("template", "").endswith("build-test.yml") + ) + credentials = test["parameters"]["EnvVars"][RUST_GATE] + build = load_template("steps/build-package-artifacts.yml") + generate = next(step for step in build["steps"] if step.get("displayName") == "Generate Packages") + assert credentials == generate["env"][RUST_GATE] + assert "${{ insert }}" in test["parameters"]["EnvVars"] + assert steps.index(next(step for step in steps if isinstance(step, dict) and RUST_GATE in step)) < steps.index(test) + + run_tests = load_template("steps/build-test.yml")["steps"] + federated = next(step for step in run_tests if "${{ if eq('true', parameters.UseFederatedAuth) }}" in step) + non_federated = next(step for step in run_tests if "${{ else }}" in step) + for step in federated["${{ if eq('true', parameters.UseFederatedAuth) }}"] + non_federated["${{ else }}"]: + environment = step["env"] + assert ( + environment.get("${{ insert }}") if isinstance(environment, dict) else environment + ) == "${{ parameters.EnvVars }}" + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_live_test_container_forwarding_preserves_non_rust_builds(enabled): + steps = load_template("jobs/live.tests.yml")["jobs"][0]["steps"] + configure = next( + step + for step in steps + if isinstance(step, dict) and step.get("displayName") == "Configure cibuildwheel for dev requirement builds" + ) + build_steps = load_template("steps/build-package-artifacts.yml")["steps"] + build_forwarding = next( + step for step in build_steps if step.get("displayName") == "Forward build environment to cibuildwheel" + ) + assert configure["env"] == build_forwarding["env"] + extra = configure["env"][RUST_GATE]["RUST_ENVIRONMENT_PASS"] if enabled else "" + result = run_powershell(configure["pwsh"], RUST_ENVIRONMENT_PASS=extra) + assert result.returncode == 0, result.stderr + expected = "PIP_INDEX_URL" + (" " + extra if enabled else "") + assert f"variable=CIBW_ENVIRONMENT_PASS_LINUX]{expected}\n" in result.stdout + assert "variable=CIBW_ARCHS]native" in result.stdout + assert "variable=CIBW_SKIP]pp* *musllinux*" in result.stdout + assert "variable=CIBW_TEST_SKIP]*" in result.stdout + + +@pytest.mark.parametrize("sign_binaries", [False, True]) +def test_platform_selection_uses_artifact_opt_in(tmp_path, sign_binaries): + package_info = {"Name": "example-native-package", "ArtifactDetails": {"signBinaries": sign_binaries}} + (tmp_path / "example-native-package.json").write_text(json.dumps(package_info)) + resolver = load_template("steps/resolve-build-platforms.yml")["steps"][0]["pwsh"] + resolver = resolver.replace("${{ parameters.PackagePropertiesFolder }}", str(tmp_path)) + + result = run_powershell(resolver) + + assert result.returncode == 0, result.stderr + assert ("variable=ENABLE_EXTENSION_BUILD]true" in result.stdout) is sign_binaries + assert f"variable=SDK_BUILD_LOGLEVEL]{'DEBUG' if sign_binaries else 'INFO'}" in result.stdout + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_container_forwarding_preserves_non_rust_builds(enabled): + build = load_template("steps/build-package-artifacts.yml") + forwarding = next( + step for step in build["steps"] if step.get("displayName") == "Forward build environment to cibuildwheel" + ) + extra = forwarding["env"][RUST_GATE]["RUST_ENVIRONMENT_PASS"] if enabled else "" + + result = run_powershell(forwarding["pwsh"], RUST_ENVIRONMENT_PASS=extra) + + assert result.returncode == 0, result.stderr + assert "variable=CIBW_ENVIRONMENT_PASS_LINUX]PIP_INDEX_URL" in result.stdout + assert ("MSRUSTUP_ACCESS_TOKEN" in result.stdout) is enabled + assert ("CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_TOKEN" in result.stdout) is enabled + + +@pytest.mark.parametrize("missing", ["feed", "directory", "toolchain"]) +def test_rust_installation_requires_explicit_configuration(tmp_path, missing): + validation = load_template("steps/install-msrust-toolchain.yml")["steps"][0]["pwsh"] + if missing != "toolchain": + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "ms-prod-1.97"\n') + result = run_powershell( + validation, + RUST_TOOLCHAIN_FEED=( + "" if missing == "feed" else "https://pkgs.dev.azure.com/azure-sdk/_packaging/example/nuget/v3/index.json" + ), + RUST_WORKING_DIRECTORY="" if missing == "directory" else str(tmp_path), + ) + assert result.returncode != 0 + + +def test_rust_installation_accepts_explicit_configuration(tmp_path): + validation = load_template("steps/install-msrust-toolchain.yml")["steps"][0]["pwsh"] + (tmp_path / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "ms-prod-1.97"\n') + result = run_powershell( + validation, + RUST_TOOLCHAIN_FEED="https://pkgs.dev.azure.com/azure-sdk/_packaging/example/nuget/v3/index.json", + RUST_WORKING_DIRECTORY=str(tmp_path), + ) + assert result.returncode == 0, result.stderr + + +def test_cargo_feed_configuration_does_not_overwrite_existing_config(tmp_path): + configure = load_template("steps/install-msrust-toolchain.yml")["steps"][2]["pwsh"] + feed = "sparse+https://pkgs.dev.azure.com/azure-sdk/public/_packaging/example/Cargo/index/" + result = run_powershell( + configure, CARGO_HOME=str(tmp_path), CARGO_FEED_URL=feed, RUST_TOOLCHAIN_FEED="example-feed" + ) + assert result.returncode == 0, result.stderr + config = (tmp_path / "config.toml").read_text() + assert feed in config + assert 'replace-with = "azure-sdk-for-rust-public"' in config + assert "token" not in config + assert "variable=CARGO_REGISTRIES_AZURE_SDK_FOR_RUST_PUBLIC_INDEX]" + feed in result.stdout + + result = run_powershell( + configure, CARGO_HOME=str(tmp_path), CARGO_FEED_URL=feed, RUST_TOOLCHAIN_FEED="example-feed" + ) + assert result.returncode != 0 + assert (tmp_path / "config.toml").read_text() == config + + +def test_cargo_feed_configuration_uses_default_cargo_home(tmp_path): + configure = load_template("steps/install-msrust-toolchain.yml")["steps"][2]["pwsh"] + feed = "sparse+https://pkgs.dev.azure.com/azure-sdk/public/_packaging/example/Cargo/index/" + + result = run_powershell( + configure, + CARGO_HOME="", + CARGO_FEED_URL=feed, + HOME=str(tmp_path), + RUST_TOOLCHAIN_FEED="example-feed", + ) + + assert result.returncode == 0, result.stderr + config = tmp_path / ".cargo" / "config.toml" + assert config.is_file() + assert feed in config.read_text() + + +def test_build_powershell_scripts_parse(): + for template_path in ( + "steps/install-msrust-toolchain.yml", + "steps/build-package-artifacts.yml", + "steps/resolve-build-platforms.yml", + "jobs/live.tests.yml", + ): + template = load_template(template_path) + steps = template["jobs"][0]["steps"] if "jobs" in template else template["steps"] + for step in steps: + if not isinstance(step, dict) or "pwsh" not in step: + continue + # Azure expands template expressions and the command-valued PIP_EXE macro before parsing. + script = re.sub(r"\$\{\{.*?\}\}", "example", step["pwsh"]) + script = script.replace("$(PIP_EXE)", "python -m pip") + result = run_powershell( + """ + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseInput( + $env:SCRIPT, [ref]$tokens, [ref]$errors + ) | Out-Null + if ($errors.Count) { + $errors | ForEach-Object { Write-Error $_.Message } + exit 1 + } + """, + SCRIPT=script, + ) + assert result.returncode == 0, f"{template_path}: {step['displayName']}: {result.stderr}" diff --git a/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py b/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py index 1405850a0285..705261fde8ec 100644 --- a/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py +++ b/eng/tools/azure-sdk-tools/tests/test_parse_functionality.py @@ -1,6 +1,8 @@ from ci_tools.parsing import parse_require, ParsedSetup +from ci_tools.parsing.parse_functions import has_cibuildwheel_config from packaging.specifiers import SpecifierSet import os +import shutil from unittest.mock import patch import pytest @@ -333,3 +335,52 @@ def test_namespace_discovery_with_substantial_content(): assert result == "test.module" finally: os.unlink(temp_file) + + +@pytest.mark.parametrize( + "config", + [ + "\n[tool.cibuildwheel]\n", + '\n[tool.cibuildwheel]\nbuild = "cp310-*"\n', + '\n[tool.cibuildwheel.linux]\narchs = ["x86_64", "aarch64"]\n', + ], +) +def test_cibuildwheel_config_detected_without_ext_modules(tmp_path, config): + package = tmp_path / "package" + shutil.copytree(pyproject_scenario, package) + with (package / "pyproject.toml").open("a") as stream: + stream.write(config) + + parsed = ParsedSetup.from_path(str(package)) + assert parsed.ext_modules == [] + assert parsed.uses_cibuildwheel is True + assert has_cibuildwheel_config(str(package)) is True + + +@pytest.mark.parametrize("scenario", [pyproject_scenario, setup_project_scenario]) +def test_cibuildwheel_config_absent(scenario): + assert has_cibuildwheel_config(scenario) is False + assert ParsedSetup.from_path(scenario).uses_cibuildwheel is False + + +def test_cibuildwheel_config_detected_for_legacy_setup(tmp_path): + package = tmp_path / "package" + shutil.copytree(setup_project_scenario, package) + (package / "pyproject.toml").write_text('[tool.cibuildwheel]\nbuild = "cp310-*"\n') + + parsed = ParsedSetup.from_path(str(package)) + assert parsed.is_pyproject is False + assert parsed.uses_cibuildwheel is True + + +def test_invalid_cibuildwheel_toml_is_not_ignored(tmp_path): + (tmp_path / "pyproject.toml").write_text("[tool.cibuildwheel\n") + with pytest.raises(ValueError): + has_cibuildwheel_config(str(tmp_path)) + + +@pytest.mark.parametrize("value", ["false", '"not-a-table"']) +def test_cibuildwheel_config_requires_a_table(tmp_path, value): + (tmp_path / "pyproject.toml").write_text(f"[tool]\ncibuildwheel = {value}\n") + with pytest.raises(ValueError, match=r"Expected a \[tool.cibuildwheel\] table"): + has_cibuildwheel_config(str(tmp_path)) diff --git a/eng/tools/azure-sdk-tools/tests/test_pyproject_interactions.py b/eng/tools/azure-sdk-tools/tests/test_pyproject_interactions.py index d27a941cb74e..d16045b83766 100644 --- a/eng/tools/azure-sdk-tools/tests/test_pyproject_interactions.py +++ b/eng/tools/azure-sdk-tools/tests/test_pyproject_interactions.py @@ -76,6 +76,76 @@ def test_environment_override(check_name, environment_value, expected_result): assert result == expected_result +@pytest.fixture +def apistub_package(tmp_path, monkeypatch): + package = tmp_path / "azure-test" + package.mkdir() + for check in ("APISTUB", "PYLINT", "CI_ENABLED"): + monkeypatch.delenv(f"AZURE_TEST_{check}", raising=False) + monkeypatch.setattr("ci_tools.environment_exclusions.in_public", lambda: False) + monkeypatch.setattr("ci_tools.environment_exclusions.in_analyze_weekly", lambda: False) + return package + + +@pytest.mark.parametrize("config", ["", "[tool.azure-sdk-build]\n", "[tool.azure-sdk-build]\napistub = true\n"]) +def test_apistub_enabled(apistub_package, config): + if config: + (apistub_package / "pyproject.toml").write_text(config) + + assert is_check_enabled(str(apistub_package), "apistub") is True + + +@pytest.mark.parametrize("check", ["apistub", " APISTUB "]) +@pytest.mark.parametrize("target", ["folder", "setup.py", "."]) +def test_apistub_opt_out_rejected(apistub_package, monkeypatch, check, target): + (apistub_package / "pyproject.toml").write_text("[tool.azure-sdk-build]\napistub = false\n") + if target == ".": + monkeypatch.chdir(apistub_package) + package_path = "." + elif target == "setup.py": + package_path = str(apistub_package / "setup.py") + else: + package_path = str(apistub_package) + + with pytest.raises(ValueError, match="apistub is required") as error: + is_check_enabled(package_path, check) + + assert str(apistub_package) in str(error.value) + assert "apistub = false" in str(error.value) + assert "[tool.azure-sdk-build]" in str(error.value) + + +def test_apistub_environment_opt_out_rejected(apistub_package, monkeypatch): + (apistub_package / "pyproject.toml").write_text("[tool.azure-sdk-build]\napistub = true\n") + monkeypatch.setenv("AZURE_TEST_APISTUB", "false") + + with pytest.raises(ValueError, match="APISTUB environment override"): + is_check_enabled(str(apistub_package), "apistub") + + +def test_other_check_opt_out_preserved(apistub_package): + (apistub_package / "pyproject.toml").write_text("[tool.azure-sdk-build]\npylint = false\n") + + assert is_check_enabled(str(apistub_package), "pylint") is False + assert is_check_enabled(str(apistub_package), "black", default=False) is False + + +@pytest.mark.parametrize("build_mode", ["internal", "public", "weekly"]) +@pytest.mark.parametrize("apistub_enabled", [True, False]) +def test_apistub_ci_enabled_behavior_preserved(apistub_package, monkeypatch, build_mode, apistub_enabled): + (apistub_package / "pyproject.toml").write_text( + f"[tool.azure-sdk-build]\nci_enabled = false\napistub = {str(apistub_enabled).lower()}\n" + ) + monkeypatch.setattr("ci_tools.environment_exclusions.in_public", lambda: build_mode == "public") + monkeypatch.setattr("ci_tools.environment_exclusions.in_analyze_weekly", lambda: build_mode == "weekly") + + if build_mode != "internal" and not apistub_enabled: + with pytest.raises(ValueError, match="apistub is required"): + is_check_enabled(str(apistub_package), "apistub") + else: + assert is_check_enabled(str(apistub_package), "apistub") is (build_mode != "internal") + + def test_pyproject_update_check_override(): with tempfile.TemporaryDirectory() as temp_dir: new_path = shutil.copy(pyproject_file, temp_dir) diff --git a/eng/tools/azure-sdk-tools/tests/test_sdk_changelog.py b/eng/tools/azure-sdk-tools/tests/test_sdk_changelog.py index d9ec53326a3f..375cce32311b 100644 --- a/eng/tools/azure-sdk-tools/tests/test_sdk_changelog.py +++ b/eng/tools/azure-sdk-tools/tests/test_sdk_changelog.py @@ -184,8 +184,9 @@ def test_timeout_default_is_900(mock_get_changelog_content, temp_package): assert kwargs["timeout"] == 900 +@patch("pypi_tools.pypi.PyPIClient.project", return_value={"releases": {"1.2.3": [{}]}}) @patch("packaging_tools.sdk_changelog.get_changelog_content") -def test_output_json_detector_mode_breaking(mock_get_changelog_content, temp_arm_package): +def test_output_json_detector_mode_breaking(mock_get_changelog_content, mock_project, temp_arm_package): package_path, changelog_path = temp_arm_package md_output = "### Features Added\n\n - foo\n\n### Breaking Changes\n\n - dropped bar\n" mock_get_changelog_content.return_value = (md_output, "1.2.3") @@ -228,6 +229,136 @@ def test_output_json_detector_mode_no_breaking(mock_get_changelog_content, temp_ assert f.read() == "# Release History\n\n" +@pytest.mark.parametrize("tag_is_stable", [False, True], ids=["preview-target", "stable-target"]) +@pytest.mark.parametrize( + "releases, expected_breaking", + [ + pytest.param( + {"1.0.0b1": [{}], "1.0.0rc1": [{}], "1.0.0.dev1": [{}]}, + False, + id="preview-only", + ), + pytest.param({}, False, id="unpublished"), + pytest.param( + {"0.0.0": [{}], "0.0.0b1": [{}], "1.0.0b1": [{}]}, + False, + id="placeholder-only-stable", + ), + pytest.param({"1.0.0": [], "1.0.0b1": [{}]}, False, id="stable-without-files"), + pytest.param({"1.0.0": [{}], "2.0.0b1": [{}]}, True, id="prior-ga"), + pytest.param({"1.0.0": [{"yanked": True}], "2.0.0b1": [{}]}, True, id="yanked-prior-ga"), + pytest.param({"1.0.0.post1": [{}]}, True, id="stable-post-release"), + ], +) +def test_breaking_change_reporting_uses_published_ga_history( + temp_arm_package, tag_is_stable, releases, expected_breaking +): + package_path, changelog_path = temp_arm_package + md_output = "### Features Added\n\n - foo\n\n### Breaking Changes\n\n - dropped bar\n" + package_result = {"tagIsStable": tag_is_stable} + output_json = package_path / "changes.json" + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0b1")), + patch("pypi_tools.pypi.PyPIClient") as mock_client, + ): + mock_client.return_value.project.return_value = {"releases": releases} + + changelog_main(package_path, package_result=package_result, output_json=output_json) + + mock_client.assert_called_once_with(force_pypi=True) + mock_client.return_value.project.assert_called_once_with(package_path.name) + + assert package_result["changelog"] == { + "content": md_output, + "hasBreakingChange": expected_breaking, + "breakingChangeItems": ["dropped bar"], + } + assert package_result["version"] == "1.0.0b1" + assert json.loads(output_json.read_text(encoding="utf-8")) == { + "changes": md_output, + "hasBreakingChange": expected_breaking, + } + assert changelog_path.read_text() == "# Release History\n\n" + + +@pytest.mark.parametrize( + "project", + [{}, {"releases": None}, {"releases": {"invalid-version": [{}]}}], + ids=["missing-release-history", "invalid-release-history", "invalid-version"], +) +def test_unknown_release_history_preserves_breaking_change_reporting(temp_arm_package, project, caplog): + package_path, _ = temp_arm_package + md_output = "### Breaking Changes\n\n - dropped bar\n" + package_result = {"tagIsStable": True} + output_json = package_path / "changes.json" + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0b1")), + patch("pypi_tools.pypi.PyPIClient.project", return_value=project), + ): + changelog_main(package_path, package_result=package_result, output_json=output_json) + + assert package_result["changelog"]["hasBreakingChange"] is True + assert package_result["changelog"]["content"] == md_output + assert json.loads(output_json.read_text()) == {"changes": md_output, "hasBreakingChange": True} + assert "keeping breaking-change reporting enabled" in caplog.text + + +def test_release_history_lookup_failure_preserves_breaking_change_reporting(temp_arm_package, caplog): + package_path, _ = temp_arm_package + md_output = "### Breaking Changes\n\n - dropped bar\n" + package_result = {"tagIsStable": True} + output_json = package_path / "changes.json" + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0b1")), + patch("pypi_tools.pypi.PyPIClient.project", side_effect=RuntimeError("PyPI unavailable")), + ): + changelog_main(package_path, package_result=package_result, output_json=output_json) + + assert package_result["changelog"]["hasBreakingChange"] is True + assert package_result["changelog"]["breakingChangeItems"] == ["dropped bar"] + assert json.loads(output_json.read_text()) == {"changes": md_output, "hasBreakingChange": True} + assert "PyPI unavailable" in caplog.text + assert "keeping breaking-change reporting enabled" in caplog.text + + +def test_pre_ga_changelog_preserves_breaking_change_details(temp_arm_package): + package_path, changelog_path = temp_arm_package + md_output = "### Breaking Changes\n\n - dropped bar\n" + package_result = {"tagIsStable": True} + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0b1")), + patch("pypi_tools.pypi.PyPIClient.project", return_value={"releases": {"1.0.0b1": [{}]}}), + ): + changelog_main(package_path, package_result=package_result) + + assert package_result["changelog"]["hasBreakingChange"] is False + assert package_result["changelog"]["breakingChangeItems"] == ["dropped bar"] + assert md_output in changelog_path.read_text() + + +def test_non_breaking_changes_do_not_query_release_history(temp_arm_package): + package_path, _ = temp_arm_package + md_output = "### Features Added\n\n - new feature\n" + package_result = {"tagIsStable": True} + output_json = package_path / "changes.json" + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0")), + patch("pypi_tools.pypi.PyPIClient") as mock_client, + ): + changelog_main(package_path, package_result=package_result, output_json=output_json) + + mock_client.assert_not_called() + + assert package_result["changelog"]["hasBreakingChange"] is False + assert package_result["changelog"]["breakingChangeItems"] == [] + assert json.loads(output_json.read_text()) == {"changes": md_output, "hasBreakingChange": False} + + def _make_changelog(num_versions: int, body_per_version: str = " - some change\n") -> str: lines = ["# Release History\n", "\n"] # Newest version first (highest number), matching real CHANGELOG ordering. diff --git a/eng/tools/azure-sdk-tools/tests/test_sdk_generator.py b/eng/tools/azure-sdk-tools/tests/test_sdk_generator.py index 50cf34768133..6ec0a4d69632 100644 --- a/eng/tools/azure-sdk-tools/tests/test_sdk_generator.py +++ b/eng/tools/azure-sdk-tools/tests/test_sdk_generator.py @@ -59,6 +59,7 @@ def _run_main( package_names, judge_return=False, expected_create_package_calls=None, + changelog_side_effect=None, ): """Helper that wires up all the mocks and calls main().""" _write_input(input_path, input_data) @@ -69,6 +70,7 @@ def _run_main( # get_package_names returns our controlled list mocks["get_package_names"].return_value = package_names mocks["judge_tag_preview"].return_value = judge_return + mocks["sdk_changelog_generate"].side_effect = changelog_side_effect # create_package needs a dist folder with a .whl def fake_create_package(folder, pkg): @@ -93,6 +95,54 @@ def fake_create_package(folder, pkg): return _read_output(output_path) +@pytest.mark.parametrize("sdk_release_type", ["preview", "stable"]) +@pytest.mark.parametrize( + "releases, expected_breaking", + [ + pytest.param({"1.0.0b1": [{}]}, False, id="preview-only"), + pytest.param({}, False, id="unpublished"), + pytest.param({"1.0.0": [{}], "2.0.0b1": [{}]}, True, id="prior-ga"), + ], +) +def test_generation_output_uses_published_ga_history(io_paths, tmp_path, sdk_release_type, releases, expected_breaking): + from packaging_tools.sdk_changelog import main as changelog_main + + input_path, output_path = io_paths + package_path = tmp_path / "azure-mgmt-test" + package_path.mkdir() + changelog_path = package_path / "CHANGELOG.md" + changelog_path.write_text("# Release History\n\n") + md_output = "### Breaking Changes\n\n - dropped bar\n" + + with ( + patch("packaging_tools.sdk_changelog.get_changelog_content", return_value=(md_output, "1.0.0b1")), + patch("pypi_tools.pypi.PyPIClient.project", return_value={"releases": releases}), + ): + result = _run_main( + input_path, + output_path, + { + "specFolder": "spec", + "headSha": "abc123", + "repoHttpsUrl": "https://github.com/test/repo", + "relatedTypeSpecProjectFolder": ["Microsoft.Test/Test.Management"], + "sdkReleaseType": sdk_release_type, + "runMode": "spec-pull-request", + }, + package_names=[(str(tmp_path), package_path.name)], + changelog_side_effect=changelog_main, + ) + + package = result["packages"][0] + assert package["tagIsStable"] is (sdk_release_type == "stable") + assert package["changelog"] == { + "content": md_output, + "hasBreakingChange": expected_breaking, + "breakingChangeItems": ["dropped bar"], + } + assert md_output in changelog_path.read_text() + + class TestTagIsStableForTypeSpec: """Tests for the tagIsStable logic change: TypeSpec projects use sdkReleaseType, while Swagger projects still use judge_tag_preview.""" diff --git a/eng/tools/azure-sdk-tools/tests/test_venv.py b/eng/tools/azure-sdk-tools/tests/test_venv.py new file mode 100644 index 000000000000..5ddf7640934f --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_venv.py @@ -0,0 +1,92 @@ +import json +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from ci_tools import venv +from ci_tools.scenario import generation + + +@pytest.mark.parametrize("backend", ["pip", "uv"]) +@pytest.mark.parametrize("explicit_python", [False, True]) +@pytest.mark.parametrize( + "packages", + [ + [], + [ + {"name": "azure-ai-agentserver-core", "version": "2.3.0b2"}, + {"name": "azure-core", "version": "1.37.0"}, + {"name": "azure-sdk-tools", "version": "0.0.0", "editable_project_location": "/repo/eng/tools"}, + ], + ], +) +def test_get_pip_list_output_targets_environment_and_reads_installed_versions( + monkeypatch, backend, explicit_python, packages +): + monkeypatch.setenv("AZPYSDK_PIP_IMPL", backend) + monkeypatch.setenv("VIRTUAL_ENV", "/unrelated/environment") + executable = "/target/environment/python" if explicit_python else sys.executable + expected = ["uv", "pip"] if backend == "uv" else [executable, "-m", "pip"] + expected += ["list", "--format=json", "--disable-pip-version-check"] + if backend == "uv": + expected += ["--python", executable] + + def check_output(command, **kwargs): + assert command == expected + assert kwargs == {"text": True} + return json.dumps(packages) + + monkeypatch.setattr(venv.subprocess, "check_output", check_output) + assert venv.get_pip_list_output(executable if explicit_python else None) == { + package["name"]: package["version"] for package in packages + } + + +def test_get_pip_list_output_propagates_inventory_failure(monkeypatch): + def check_output(command, **kwargs): + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(venv.subprocess, "check_output", check_output) + with pytest.raises(subprocess.CalledProcessError): + venv.get_pip_list_output() + + +@pytest.mark.parametrize("backend", ["pip", "uv"]) +def test_packaged_install_preserves_satisfied_core_artifact(monkeypatch, tmp_path, backend): + monkeypatch.setenv("AZPYSDK_PIP_IMPL", backend) + monkeypatch.delenv("SETDEVVERSION", raising=False) + artifact = tmp_path / "consumer.whl" + artifact.touch() + monkeypatch.setenv("PREBUILT_WHEEL_DIR", str(tmp_path)) + stage = tmp_path / "stage" + package = SimpleNamespace(folder=str(tmp_path), requires=["azure-ai-agentserver-core>=2.3.0b2,<3.0.0"]) + monkeypatch.setattr(generation.ParsedSetup, "from_path", lambda _: package) + monkeypatch.setattr(generation, "discover_packages", lambda *args: [artifact.name]) + monkeypatch.setattr( + venv.subprocess, + "check_output", + lambda *args, **kwargs: json.dumps([{"name": "azure-ai-agentserver-core", "version": "2.3.0b2"}]), + ) + commands = [] + monkeypatch.setattr(generation.subprocess, "check_call", lambda command, **kwargs: commands.append(command)) + + generation.create_package_and_install( + distribution_directory=str(stage), + target_setup=str(tmp_path), + skip_install=False, + cache_dir=None, + work_dir=str(stage), + force_create=False, + package_type="wheel", + pre_download_disabled=False, + python_executable=sys.executable, + ) + + expected = ["uv", "pip"] if backend == "uv" else [sys.executable, "-m", "pip"] + expected += ["install", str(artifact)] + if backend == "uv": + expected += ["--python", sys.executable] + assert commands == [expected] + assert not list((stage / "dl").iterdir()) diff --git a/eng/tools/azure-sdk-tools/tests/test_versioning.py b/eng/tools/azure-sdk-tools/tests/test_versioning.py index cd355ed80163..56e4ec9e1836 100644 --- a/eng/tools/azure-sdk-tools/tests/test_versioning.py +++ b/eng/tools/azure-sdk-tools/tests/test_versioning.py @@ -1,10 +1,13 @@ import os import shutil import re +import json from tempfile import mkdtemp import pytest +from packaging.requirements import Requirement +from ci_tools import functions from ci_tools.parsing import ParsedSetup from ci_tools.versioning.version_set_dev import set_dev_version from ci_tools.versioning.version_shared import get_classification, DEV_STATUS_REGEX @@ -92,3 +95,42 @@ def test_set_dev_version_on_setup(): # Expect version to have 'a' suffix with zero-padded build id assert parsed_after.version == "1.0.0a20250813001" assert "Development Status :: 4 - Beta" in parsed_after.classifiers + + +@pytest.mark.parametrize("filename", ["setup.py", "pyproject.toml"]) +@pytest.mark.parametrize( + "requirement", + [ + "azure-ai-agentserver-core>=2.3.0b2,<3.0.0", + "azure-ai-agentserver-core<3.0.0,>=2.3.0b2", + "azure-ai-agentserver-core >= 2.3.0b2, < 3.0.0", + ], +) +@pytest.mark.parametrize("is_dev_build, available_on_pypi", [(False, False), (True, True), (False, True)]) +def test_process_requires_updates_original_requirement_text( + tmp_path, monkeypatch, filename, requirement, is_dev_build, available_on_pypi +): + dependencies = json.dumps([requirement, "requests>=2.0.0"]) + if filename == "pyproject.toml": + contents = f'[project]\nname = "test-package"\nversion = "1.0.0"\ndependencies = {dependencies}\n' + else: + contents = ( + "from setuptools import setup\n" + f'setup(name="test-package", version="1.0.0", install_requires={dependencies})\n' + ) + path = tmp_path / filename + path.write_text(contents, encoding="utf-8") + monkeypatch.setattr(functions, "is_required_version_on_pypi", lambda *args: available_on_pypi) + monkeypatch.setattr(functions, "get_version_from_repo", lambda *args: "2.3.0b3") + monkeypatch.setattr(functions, "get_base_version", lambda *args: "2.3.0") + + functions.process_requires(str(path), is_dev_build=is_dev_build) + + replacement = "azure-ai-agentserver-core<3.0.0,>=2.3.0a1,<2.3.0b0" + should_update = is_dev_build or not available_on_pypi + expected = contents.replace(requirement, replacement) if should_update else contents + assert path.read_text(encoding="utf-8") == expected + requirements = [Requirement(value) for value in ParsedSetup.from_path(str(path)).requires] + core = next(value for value in requirements if value.name == "azure-ai-agentserver-core") + assert core.specifier.contains("2.3.0a20261029003", prereleases=True) is should_update + assert core.specifier.contains("2.3.0b2", prereleases=True) is not should_update diff --git a/eng/tools/azure-sdk-tools/tests/test_weekly_analyze_pipeline.py b/eng/tools/azure-sdk-tools/tests/test_weekly_analyze_pipeline.py new file mode 100644 index 000000000000..833357869a7b --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_weekly_analyze_pipeline.py @@ -0,0 +1,103 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest +import yaml + + +TEMPLATES = Path(__file__).resolve().parents[4] / "eng" / "pipelines" / "templates" / "stages" + + +def load_template(name): + return yaml.safe_load((TEMPLATES / name).read_text()) + + +@pytest.fixture +def eligibility_step(): + stage = load_template("python-analyze-weekly.yml")["stages"][0] + check, analyze = stage["jobs"] + assert check["job"] == "CheckWeekend" + assert check["variables"]["PipelineStartTime"] == "$[ format('{0:yyyyMMddHHmmssK}', pipeline.startTime) ]" + assert check["steps"][0] == {"checkout": "none"} + assert analyze["dependsOn"] == check["job"] + step = check["steps"][1] + assert step["env"]["PIPELINE_START_TIME"] == "$(PipelineStartTime)" + assert analyze["condition"] == ( + "and(succeeded(), eq(dependencies.CheckWeekend.outputs['" + step["name"] + ".IsWeekend'], 'True'))" + ) + return step + + +@pytest.mark.parametrize( + "timestamp,expected", + [ + ("20261012000000Z", False), + ("20261013120000Z", False), + ("20261014120000Z", False), + ("20261015120000Z", False), + ("20261016235959Z", False), + ("20261017000000Z", True), + ("20261018235959Z", True), + ("20261019000000Z", False), + ("20261016170000-07:00", True), + ("20261019010000+02:00", True), + ("20261018170000-07:00", False), + ("20261017010000+02:00", False), + ("20261016235959", False), + ("20261017000000", True), + ("20261018235959", True), + ("20261019000000", False), + ("20280229000000Z", False), + ("20270102000000Z", True), + ("", None), + ("invalid", None), + ], +) +def test_weekend_eligibility_uses_pipeline_start_time(eligibility_step, timestamp, expected): + powershell = shutil.which("pwsh") + if not powershell: + pytest.skip("PowerShell is required to execute pipeline scripts") + result = subprocess.run( + [ + powershell, + "-NoProfile", + "-NonInteractive", + "-Command", + "$ErrorActionPreference = 'Stop'\n" + eligibility_step["pwsh"], + ], + env={**os.environ, "PIPELINE_START_TIME": timestamp, "TZ": "America/Los_Angeles"}, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if expected is None: + assert result.returncode != 0 + assert "variable=IsWeekend" not in result.stdout + else: + assert result.returncode == 0, result.stderr + assert f"##vso[task.setvariable variable=IsWeekend;isOutput=true]{expected}" in result.stdout + + +def test_callers_preserve_scheduled_run_guards(): + archetype = load_template("archetype-sdk-tests.yml") + packages = next(iter(archetype["extends"]["parameters"]["stages"][0].values())) + weekly = packages[1]["${{ if eq(variables['Build.Reason'], 'Schedule') }}"][0] + assert weekly["template"].endswith("/python-analyze-weekly.yml") + assert weekly["parameters"]["Condition"] == "succeeded()" + + standalone = load_template("python-analyze-weekly-standalone.yml") + weekly = standalone["extends"]["parameters"]["stages"][0] + assert weekly["template"].endswith("/python-analyze-weekly.yml") + assert weekly["parameters"]["Condition"] == "and(succeeded(), eq(variables['Build.Reason'], 'Schedule'))" + + for name in ("archetype-sdk-tests.yml", "python-analyze-weekly-standalone.yml", "python-analyze-weekly.yml"): + assert "{0:ddd}" not in (TEMPLATES / name).read_text() diff --git a/eng/tools/azure-sdk-tools/tests/test_windows_wheels.py b/eng/tools/azure-sdk-tools/tests/test_windows_wheels.py new file mode 100644 index 000000000000..87d7eed0a361 --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_windows_wheels.py @@ -0,0 +1,363 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from importlib import import_module +from importlib.util import find_spec +import logging +import os +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +from cibuildwheel.options import CommandLineArguments, Options +import pytest + +from ci_tools import windows_wheels + + +FEED = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" +WINDOWS = import_module( + "cibuildwheel.platforms.windows" if find_spec("cibuildwheel.platforms") else "cibuildwheel.windows" +) +RUN = subprocess.run + + +@pytest.fixture +def provision(tmp_path, monkeypatch): + for name in list(os.environ): + if name.startswith("CIBW_") or name.startswith("AZURESDK_PYTHON_"): + monkeypatch.delenv(name) + monkeypatch.setattr(windows_wheels.sys, "platform", "win32") + monkeypatch.setattr(windows_wheels.platform, "machine", lambda: "AMD64") + monkeypatch.setenv("AZURESDK_PYTHON_NUGET_FEED", FEED) + monkeypatch.setenv("AZURESDK_PYTHON_ARM64_LIBS", "true") + monkeypatch.setenv("CIBW_CACHE_PATH", str(tmp_path / "cache with spaces")) + package = tmp_path / "package" + package.mkdir() + calls = [] + + def install(command, **kwargs): + if command[0] != "nuget": + return RUN(command, **kwargs) + assert kwargs == {"check": True} + assert command[command.index("-Source") + 1] == FEED + assert "https://api.nuget.org/v3/index.json" not in command + calls.append(command) + name = command[2] + version = command[command.index("-Version") + 1] + tools = Path(command[command.index("-OutputDirectory") + 1]) / f"{name}.{version}" / "tools" + tools.mkdir(parents=True) + (tools / "python.exe").touch() + libraries = tools / "libs" + libraries.mkdir() + suffix = "t" if name.endswith("-freethreaded") else "" + (libraries / f"python{''.join(version.split('.')[:2])}{suffix}.lib").touch() + + monkeypatch.setattr(windows_wheels.subprocess, "run", install) + return package, calls + + +def write_config(package, build="cp31{0,1,2,3,4}-*", archs="AMD64 ARM64"): + (package / "pyproject.toml").write_text( + f""" +[project] +name = "native-test" +version = "1.0" +requires-python = ">=3.10,<3.15" +[tool.cibuildwheel] +build = "{build}" +environment = {{ KEEP = "package", PYTHON_ARM64_LIB_DIR = "old-3.10-libs" }} +before-all = "prepare-once" +before-build = "build-hook" +[tool.cibuildwheel.windows] +archs = "{archs}" +[[tool.cibuildwheel.overrides]] +select = "cp312-*" +environment = {{ KEEP = "override" }} +""", + encoding="utf-8", + ) + + +def selected_options(package, config="", environment=None): + arguments = CommandLineArguments.defaults() + arguments.package_dir = package + arguments.config_file = config + return Options(platform="windows", command_line_arguments=arguments, env=environment or os.environ) + + +def read_pyo3_config(path): + return dict(line.split("=", 1) for line in path.read_text(encoding="utf-8").splitlines()) + + +def test_selected_versions_and_matching_arm64_libraries(provision, monkeypatch): + package, calls = provision + write_config(package) + # Exercise 3.14 even on Python 3.10 hosts, whose cibuildwheel 2.x metadata + # predates it. All other targets and versions come from the installed metadata. + configurations = WINDOWS.get_python_configurations( + selected_options(package).globals.build_selector, selected_options(package).globals.architectures + ) + if not any(configuration.identifier.startswith("cp314-") for configuration in configurations): + configurations += [ + WINDOWS.PythonConfiguration(version="3.14.8", arch=arch, identifier=f"cp314-{tag}") + for arch, tag in (("64", "win_amd64"), ("ARM64", "win_arm64")) + ] + monkeypatch.setattr(WINDOWS, "get_python_configurations", lambda *args: configurations) + monkeypatch.setenv("CIBW_ENVIRONMENT", 'KEEP="pipeline" EXTRA="$PYTHON_VERSION"') + original = (package / "pyproject.toml").read_bytes() + before = os.environ.copy() + pyo3_configs = [] + + with windows_wheels.windows_wheel_config(package) as (arguments, kwargs): + assert arguments[0] == "--config-file" + config = Path(arguments[1]) + options = selected_options(package, str(config), kwargs["env"]) + assert options.globals.build_selector == selected_options(package).globals.build_selector + assert options.globals.architectures == selected_options(package).globals.architectures + for configuration in configurations: + build = options.build_options(configuration.identifier) + environment = build.environment.as_dictionary({"PYTHON_VERSION": configuration.version}) + assert environment["KEEP"] == "pipeline" + assert environment["EXTRA"] == configuration.version + assert build.before_all == "prepare-once" + assert build.before_build == "build-hook" + if configuration.arch == "ARM64": + expected = ( + Path(os.environ["CIBW_CACHE_PATH"]) + / "nuget-cpython" + / f"pythonarm64.{configuration.version}" + / "tools" + / "libs" + ) + assert Path(environment["PYTHON_ARM64_LIB_DIR"]) == expected + assert "PYO3_CROSS_LIB_DIR" not in environment + pyo3_config = Path(environment["PYO3_CONFIG_FILE"]) + pyo3_configs.append(pyo3_config) + assert pyo3_config.is_file() + target = read_pyo3_config(pyo3_config) + major, minor = configuration.version.split(".")[:2] + assert target["version"] == f"{major}.{minor}" + assert Path(target["lib_dir"]) == expected + assert target["ext_suffix"] == f".cp{major}{minor}-win_arm64.pyd" + assert target["build_flags"] == "" + assert "abiflags" not in target + else: + assert "PYO3_CROSS_LIB_DIR" not in environment + assert "PYTHON_ARM64_LIB_DIR" not in environment + assert "PYO3_CONFIG_FILE" not in environment + assert os.environ == before + + assert not config.exists() + assert pyo3_configs + assert not any(path.exists() for path in pyo3_configs) + assert (package / "pyproject.toml").read_bytes() == original + expected = {(configuration.version, configuration.arch) for configuration in configurations} + actual = { + (command[command.index("-Version") + 1], "ARM64" if command[2] == "pythonarm64" else "64") for command in calls + } + assert actual == expected + assert len(calls) == len(expected) + assert {version.split(".")[1] for version, _ in actual} == {"10", "11", "12", "13", "14"} + + +@pytest.mark.parametrize("archs", ["AMD64 ARM64", "ARM64", "AMD64"]) +@pytest.mark.parametrize("rust", ["true", "false"]) +def test_cp310_only_and_cache_reuse(provision, monkeypatch, archs, rust): + package, calls = provision + write_config(package, build="cp310-*", archs=archs) + monkeypatch.setenv("AZURESDK_PYTHON_ARM64_LIBS", rust) + options = selected_options(package) + configurations = WINDOWS.get_python_configurations(options.globals.build_selector, options.globals.architectures) + expected_versions = {configuration.version for configuration in configurations} + assert configurations + assert all(configuration.identifier.startswith("cp310-") for configuration in configurations) + for _ in range(2): + with windows_wheels.windows_wheel_config(package) as (arguments, kwargs): + assert bool(arguments) is (rust == "true" and "ARM64" in archs) + assert {command[command.index("-Version") + 1] for command in calls} == expected_versions + assert {command[2] for command in calls} == ({"python", "pythonarm64"} if "ARM64" in archs else {"python"}) + assert len(calls) == (2 if "ARM64" in archs else 1) + + +@pytest.mark.parametrize("platform_environment", [None, 'KEEP="windows" EXPAND="$PYTHON_VERSION"']) +def test_package_overrides_and_windows_environment_are_preserved(provision, monkeypatch, platform_environment): + package, calls = provision + write_config(package) + if platform_environment: + monkeypatch.setenv("CIBW_ENVIRONMENT_WINDOWS", platform_environment) + original = selected_options(package) + configurations = WINDOWS.get_python_configurations(original.globals.build_selector, original.globals.architectures) + with windows_wheels.windows_wheel_config(package) as (arguments, kwargs): + options = selected_options(package, arguments[1], kwargs["env"]) + for configuration in configurations: + previous = {"PYTHON_VERSION": configuration.version} + expected = original.build_options(configuration.identifier).environment.as_dictionary(previous) + actual = options.build_options(configuration.identifier).environment.as_dictionary(previous) + if configuration.arch == "ARM64": + actual.pop("PYTHON_ARM64_LIB_DIR") + actual.pop("PYO3_CONFIG_FILE") + expected.pop("PYTHON_ARM64_LIB_DIR", None) + assert actual == expected + + +def test_temporary_config_preserves_actual_cli_build_identifiers(provision): + package, calls = provision + write_config(package) + options = selected_options(package) + expected = [ + configuration.identifier + for configuration in WINDOWS.get_python_configurations( + options.globals.build_selector, options.globals.architectures + ) + ] + with windows_wheels.windows_wheel_config(package) as (arguments, kwargs): + result = RUN( + [ + sys.executable, + "-m", + "cibuildwheel", + "--platform", + "windows", + "--print-build-identifiers", + *arguments, + str(package), + ], + capture_output=True, + text=True, + check=True, + **kwargs, + ) + assert result.stdout.splitlines() == expected + + +def test_free_threaded_package_and_libraries_match(provision, monkeypatch): + package, calls = provision + write_config(package) + configuration = SimpleNamespace(identifier="cp314t-win_arm64", version="3.14.8", arch="ARM64") + monkeypatch.setattr(WINDOWS, "get_python_configurations", lambda *args: [configuration]) + with windows_wheels.windows_wheel_config(package) as (arguments, kwargs): + options = selected_options(package, arguments[1], kwargs["env"]) + environment = options.build_options(configuration.identifier).environment.as_dictionary({}) + libraries = Path(environment["PYTHON_ARM64_LIB_DIR"]) + assert libraries.parent.parent.name == "pythonarm64-freethreaded.3.14.8" + assert (libraries / "python314t.lib").is_file() + assert "PYO3_CROSS_LIB_DIR" not in environment + target = read_pyo3_config(Path(environment["PYO3_CONFIG_FILE"])) + assert target["version"] == "3.14" + assert Path(target["lib_dir"]) == libraries + assert target["build_flags"] == "Py_GIL_DISABLED" + assert target["abiflags"] == "t" + assert target["ext_suffix"] == ".cp314t-win_arm64.pyd" + assert {command[2] for command in calls} == {"python-freethreaded", "pythonarm64-freethreaded"} + + +def test_native_arm64_host_does_not_provision_amd64(provision, monkeypatch): + package, calls = provision + write_config(package, build="cp310-*", archs="ARM64") + monkeypatch.setattr(windows_wheels.platform, "machine", lambda: "ARM64") + with windows_wheels.windows_wheel_config(package): + pass + assert [command[2] for command in calls] == ["pythonarm64"] + + +@pytest.mark.parametrize("is_pyproject", [True, False]) +@pytest.mark.parametrize("stream", [False, True]) +def test_sdk_build_uses_temporary_config_and_cleans_up_on_failure(provision, monkeypatch, is_pyproject, stream): + from ci_tools import build + + package, calls = provision + write_config(package, build="cp310-*") + monkeypatch.setattr( + build.ParsedSetup, + "from_path", + lambda _: SimpleNamespace( + is_pyproject=is_pyproject, ext_modules=True, uses_cibuildwheel=True, folder=str(package) + ), + ) + configs = [] + install = windows_wheels.subprocess.run + monkeypatch.setattr(build.logger, "level", logging.DEBUG if stream else logging.INFO) + + def run(command, **kwargs): + if command[0] == "nuget": + return install(command, **kwargs) + assert command[2] == "cibuildwheel" + config = Path(command[command.index("--config-file") + 1]) + assert config.is_file() + assert "CIBW_ENVIRONMENT" not in kwargs["env"] + assert kwargs["cwd"] == str(package) + assert kwargs["check"] is True + assert kwargs["text"] is True + assert ("stdout" not in kwargs) is stream + assert kwargs["stderr"] == subprocess.STDOUT + configs.append(config) + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(windows_wheels.subprocess, "run", run) + with pytest.raises(subprocess.CalledProcessError): + build.create_package(str(package), str(package / "dist"), enable_sdist=False) + assert len(configs) == 1 + assert not configs[0].exists() + + +def test_sdist_only_does_not_provision_interpreters(provision, monkeypatch): + from ci_tools import build + + package, calls = provision + monkeypatch.setattr( + build.ParsedSetup, + "from_path", + lambda _: SimpleNamespace(is_pyproject=True, ext_modules=True, uses_cibuildwheel=True, folder=str(package)), + ) + commands = [] + monkeypatch.setattr(build, "run_logged", lambda command, **kwargs: commands.append(command)) + build.create_package(str(package), str(package / "dist"), enable_wheel=False) + assert not calls + assert len(commands) == 1 + assert commands[0][2] == "build" + + +def test_skip_requires_python_and_environment_architecture_selection(provision, monkeypatch): + package, calls = provision + write_config(package) + monkeypatch.setenv("CIBW_SKIP", "cp310-*") + monkeypatch.setenv("CIBW_PROJECT_REQUIRES_PYTHON", ">=3.10,<3.12") + monkeypatch.setenv("CIBW_ARCHS_WINDOWS", "AMD64") + with windows_wheels.windows_wheel_config(package) as result: + assert result == ([], {}) + assert [(command[2], command[command.index("-Version") + 1]) for command in calls] == [("python", "3.11.9")] + + +@pytest.mark.parametrize("failure", ["nuget", "executable", "libraries"]) +def test_provisioning_errors_propagate(provision, monkeypatch, failure): + package, calls = provision + write_config(package, build="cp310-*", archs="ARM64") + install = windows_wheels.subprocess.run + + def fail(command, **kwargs): + if failure == "nuget": + raise subprocess.CalledProcessError(1, command) + install(command, **kwargs) + tools = Path(command[command.index("-OutputDirectory") + 1]) / "pythonarm64.3.10.11" / "tools" + if failure == "executable": + (tools / "python.exe").unlink() + else: + (tools / "libs" / "python310.lib").unlink() + + monkeypatch.setattr(windows_wheels.subprocess, "run", fail) + with pytest.raises(subprocess.CalledProcessError if failure == "nuget" else RuntimeError): + with windows_wheels.windows_wheel_config(package): + pytest.fail("A failed provision must not start cibuildwheel") + + +@pytest.mark.parametrize("platform,feed", [("linux", FEED), ("darwin", FEED), ("win32", "")]) +def test_non_pipeline_builds_are_unchanged(tmp_path, monkeypatch, platform, feed): + monkeypatch.setattr(windows_wheels.sys, "platform", platform) + monkeypatch.setenv("AZURESDK_PYTHON_NUGET_FEED", feed) + with windows_wheels.windows_wheel_config(tmp_path) as result: + assert result == ([], {}) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-a2a/CHANGELOG.md new file mode 100644 index 000000000000..6f311cbf946d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/CHANGELOG.md @@ -0,0 +1,77 @@ +# Release History + +## 1.0.0b1 (2026-11-02) + +### Features Added + +- Added `FoundryTaskStore`, implementing native Task persistence with Core's + generic state store, caller isolation, and native list/filter/page semantics. +- Added executor-based `A2AAgentServerHost` construction with hosted Foundry + storage/local in-memory defaults and explicit task-store overrides. Existing + caller-supplied request handlers keep their storage unchanged. +- Added a state-store sample demonstrating Task retrieval after a server restart; + task persistence does not enable automatic execution recovery. +- Initial preview release of `azure-ai-agentserver-a2a`. +- Added `A2AAgentServerHost` for hosting native A2A v1.0 `AgentCard` and + `RequestHandler` objects on the canonical Foundry container routes. +- Added request-scoped capture of `x-agent-foundry-call-id` and + `x-agent-user-id`. +- Added `x-platform-error-source` classification for transport-level HTTP + errors. +- Added cooperative composition with other Agent Server protocol hosts. +- Added development-stage OpenTelemetry A2A server spans for JSON-RPC + operations, including Agent Card metadata, request task + and conversation correlation, and unary JSON-RPC error classification. +- Added non-advertised aliases at `POST /invocations/a2a` and + `GET /invocations/a2a/.well-known/agent-card.json`. + +### Other Changes + +- Marked the public A2A host and task-store APIs as experimental. +- Added Python 3.15 metadata and `httpx2` sample/test HTTP clients. +- Simplified A2A tracing to reuse Core's span lifecycle helpers. Response inspection + captures only a missing Task ID for correlation, without collecting response-only + context IDs or task states. +- Consolidated tracing into one response wrapper and one span cleanup path for + endpoint execution and response transmission, with shared JSON decoding. +- Requires `azure-ai-agentserver-core>=2.3.1b1,<3.0.0` for preservation of Agent + Card metadata and the non-blocking span flush helper used by A2A tracing. +- Added an opt-in internal manual pipeline mode to publish A2A and core alpha + packages together to the Azure SDK dev feed. +- Added sample setup instructions, a native local client, and explicit in-memory + state and durability guidance. + +### Bugs Fixed + +- Bound encoded listing metadata to the storage tag limit without truncating + Tasks; oversized summaries use the full-snapshot listing path. +- Bound storage item keys to 128 characters using owner-scoped hashes while + preserving the complete original Task identifier. +- Resolve header and query protocol selectors consistently for native dispatch + and tracing. Reject conflicting selectors and unsupported minor versions. +- Select native Task-list pages from lightweight metadata before reading full + snapshots, avoiding history/artifact downloads for every retained Task. +- Preserve explicit request conversation IDs on both A2A request spans when + inbound baggage contains a conflicting conversation ID. +- Use an identifier-safe owner tag for managed state-store queries so hosted + `ListTasks` succeeds as well as `GetTask`. +- Add a deployment-aware `invoke_agent` parent for hosted A2A message sends so + trace discovery does not depend on model calls or native implementation spans. + Keep Agent Card metadata on the child A2A span and preserve Task-ID correlation + and error classification on both spans. +- Support validated brokered storage context when the A2A gateway omits the + optional raw user-ID header; verify actual server-side user isolation before + every item operation and keep per-request call IDs out of storage keys. +- Require native `a2a-sdk>=1.2.3` so active-task subscription/cancellation + cache hits authorize through the caller-scoped task store. +- Preserve shared request ID and server-version headers on exception-generated + error responses. +- Read telemetry identifiers only from A2A protocol fields, + without interpreting application metadata or data-part contents. +- Omit A2A from the tracing-only live-test pipeline until it has a marked + `tracing_e2e` suite; normal CI validation remains enabled. +- Keep pipeline artifact entries compatible with raw YAML namespace metadata + extraction while limiting manual A2A alpha uploads to A2A and core. +- Align manual A2A alpha documentation updates with the packages actually + published, register the native SDK dependency for shared analysis, and add + the required README troubleshooting and next-steps sections. diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/LICENSE b/sdk/agentserver/azure-ai-agentserver-a2a/LICENSE new file mode 100644 index 000000000000..4c3581d3b052 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/MANIFEST.in b/sdk/agentserver/azure-ai-agentserver-a2a/MANIFEST.in new file mode 100644 index 000000000000..f33ec624a5c0 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/MANIFEST.in @@ -0,0 +1,8 @@ +include *.md +include LICENSE +recursive-include tests *.py *.json +recursive-include samples *.py *.md *.txt +include azure/__init__.py +include azure/ai/__init__.py +include azure/ai/agentserver/__init__.py +include azure/ai/agentserver/a2a/py.typed diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/README.md b/sdk/agentserver/azure-ai-agentserver-a2a/README.md new file mode 100644 index 000000000000..273c33da4f62 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/README.md @@ -0,0 +1,349 @@ +# Azure AI Agent Server A2A client library for Python + +The `azure-ai-agentserver-a2a` package hosts a native A2A v1.0 +[`a2a-sdk`](https://pypi.org/project/a2a-sdk/) handler in an Azure AI Hosted +Agent container. It adds Foundry's hosting contract while leaving the agent +card, executor, request handler, and protocol semantics in the native A2A SDK. +It can also persist native Tasks using Core's existing Foundry state store. + +The package is optional: customers can implement the documented Foundry +container contract themselves. It is the recommended convenience layer because +it reduces the required route, lifecycle, telemetry, and request-context glue +to a small host declaration. The container contract itself remains mandatory. + +## Getting started + +### Install the package + +```bash +python -m pip install "azure-ai-agentserver-a2a==1.0.0b1" +``` + +### Build and install an alpha + +Alpha packages use the same internal dev-feed workflow as the Activity host. +They are not published to PyPI. + +1. Commit and push the A2A and core changes to a branch available to the + [internal `python - agentserver` pipeline](https://dev.azure.com/azure-sdk/internal/_build?definitionId=7974). + A fork-only branch must first be made available to that pipeline's source + repository by a maintainer. +2. Select **Run pipeline**, choose that branch, and enable + **Publish A2A and core alpha packages to the dev feed** (`PublishA2AAlpha`). +3. Keep **Build** and **Integration** selected. The opt-in applies only to + internal manual runs; it sets `SetDevVersion=true` and `Skip.Release=true`. + Leave the `BuildTargetingString` variable unset so neither package is + filtered out. Normal CI, scheduled builds, and PR validation are unchanged. +4. Check the Integration job's publish steps for both + `azure-ai-agentserver-a2a` and `azure-ai-agentserver-core`. Normal validation + still covers the service packages, but this mode publishes only these two + alpha packages, not the other Agent Server hosts. + +The pipeline generates versions from its build number without changing the +checked-in versions. For example, build `20261030.1` produces +`1.0.0a20261030001` (A2A) and `2.3.1a20261030001` (core). +Replace the example versions below with those from the successful run: + +```powershell +python -m pip install --pre ` + --index-url "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" ` + "azure-ai-agentserver-a2a==1.0.0a20261030001" ` + "azure-ai-agentserver-core==2.3.1a20261030001" +``` + +Install both packages from the same run to include the core tracing enrichment +changes. The dev-build tooling rewrites A2A's core dependency to an alpha range; +pinning both versions also prevents accidentally testing a different core build. +Regular preview installations require core 2.3.1b1 or later, which contains the +Agent Card metadata-preservation fix. + +### Prerequisites + +- Python 3.10 or later +- `a2a-sdk>=1.2.3,<2.0.0`, which includes owner authorization on active-task + cache hits used by subscription and cancellation +- A native A2A v1.0 `AgentCard` and either an `AgentExecutor` or `RequestHandler` + +Requests select protocol version `1.0` with the `A2A-Version` header or the +`a2a-version` query parameter. Selector names are case-insensitive. Matching +selectors are accepted; conflicting selectors return an invalid-parameters +error. Omitted or empty selectors use the protocol's `0.3` default, +which this v1-only host rejects. Unsupported versions also return a +version-not-supported error. Trace protocol-version attributes describe an +accepted selection, not an unsupported requested version. + +## Key concepts + +### A2AAgentServerHost + +`A2AAgentServerHost` is an `AgentServerHost` subclass that: + +- registers the native JSON-RPC handler at `POST /a2a`; +- accepts `POST /invocations/a2a` and + `GET /invocations/a2a/.well-known/agent-card.json` as non-advertised aliases; +- registers the native public agent card at + `GET /a2a/.well-known/agent-card.json`; +- captures `x-agent-foundry-call-id` and `x-agent-user-id` in the + request-scoped Foundry context; +- provides `/readiness`, port configuration, telemetry, and graceful shutdown + through `azure-ai-agentserver-core`. +- builds a native default request handler when given an executor, selecting + Foundry-backed task storage when hosted and native in-memory storage locally. + + Both POST routes execute the same native handler and semantic tracing wrapper. + Both card routes use the same native card handler and `card_modifier`; the + alias does not rewrite the card or advertise an extra interface. + +The Foundry passthrough advertises only the JSON-RPC binding. Push notifications, +the authenticated extended card, HTTP+JSON/REST, and gRPC are not exposed in the +initial release. + +### Distributed tracing + +In Foundry-hosted environments, `SendMessage` also emits one `invoke_agent` +parent span using the deployed Foundry agent name, +version, and identity. This keeps hosted-agent trace discovery independent of +LLM calls or native SDK implementation spans. The child A2A operation span +retains Agent Card metadata, which can differ from the deployment name/version. +Both spans cover the complete response and record a returned Task ID for +correlation. Task lookup/list/cancel operations +do not emit an `invoke_agent` span because they do not execute the agent. + +The host emits one `SERVER` span for each valid A2A JSON-RPC operation using +the `Azure.AI.AgentServer.A2A` instrumentation scope. Span names are the A2A +method names (`SendMessage`, `GetTask`, and so on). +Attributes include Agent Card metadata and protocol, message, task, and `contextId` +identifiers supplied in the request. The host reuses Core's trace propagation, +exception recording, span completion, and non-blocking flush helpers. HTTP errors +and buffered unary JSON-RPC errors mark the span as failed, including JSON-RPC +errors transported with HTTP 200. + +When the request has no Task ID, tracing also records the first Task ID returned +by a single-task operation. This lets a Task-ID search find its original +creation span. +`ListTasks` is not tagged with an arbitrary result Task ID. + +Response-only context IDs and task states are not recorded. +Application metadata, prompts, and artifacts are not added to these spans. + +These attributes follow the development-stage proposal in +[OpenTelemetry GenAI semantic conventions PR 195](https://github.com/open-telemetry/semantic-conventions-genai/pull/195) +and may evolve before that convention becomes stable. + +The native `a2a-sdk` currently emits generic implementation-level spans in +addition to these semantic operation spans. To avoid duplicate server spans, +set this environment variable **before importing `a2a`**. The hosted invocation +span remains enabled: + +```bash +OTEL_INSTRUMENTATION_A2A_SDK_ENABLED=false +``` + +## Examples + +### Host a native A2A application + +```python +from azure.ai.agentserver.a2a import A2AAgentServerHost + +host = A2AAgentServerHost(agent_card=card, request_handler=handler) +host.run() +``` + +To let the host construct the native handler and select default storage: + +```python +host = A2AAgentServerHost(agent_card=card, agent_executor=executor) +host.run() +``` + +The package intentionally does not replace the native A2A SDK. Continue using +native A2A types for the agent card, executor, handler, task store, messages, +artifacts, and tasks. + +### Task persistence + +Executor-based construction follows the Activity host's storage-selection +pattern: + +| Configuration | Task storage | +|---------------|--------------| +| Explicit `task_store` | The supplied native store, unchanged | +| Foundry-hosted, no explicit store | `FoundryTaskStore` backed by Core's generic state store | +| Local, no explicit store | Native `InMemoryTaskStore` | +| Existing `request_handler` | The handler and its storage are preserved; no replacement | + +Supply exactly one of `agent_executor` and `request_handler`. When supplying +an existing handler, configure its storage directly instead of passing +`task_store` to the host. + +`FoundryTaskStore` implements the native `save`, `get`, `list`, and `delete` +operations. It stores complete protobuf Task snapshots, including history and +artifacts. Lightweight listing metadata is saved atomically with each snapshot; +native filtering and pagination select the page before full snapshots are read. +Listing still scans key metadata to preserve native ordering and total counts, +but full history/artifact reads are limited to the requested page when listing +metadata is present. +Listing metadata is also omitted when its encoded value exceeds Foundry's +256-character tag limit; the complete Task is still saved and uses the same +full-read listing path. Task-list timestamp cutoffs are inclusive. Equal +timestamps are ordered by descending Task ID, following the native SDK. +The response `pageSize` is the page limit used, not the returned Task count. +Task IDs that would exceed the 128-character storage item-key limit use an owner-scoped +hash key; their original IDs remain unchanged in the saved Task. +Foundry enforces its configured caller-isolation scope on hosted item +operations using validated brokered context. Available platform-user, native +user, and tenant scopes are layered into storage keys. A raw user-ID header is not required when the request +has a brokered call ID; the opaque call ID is forwarded unchanged through Core, +never parsed or used as a durable owner key. Hosted requests without usable +identity or brokered context are rejected. + +Every operation verifies that the actual backing-store descriptor enables +`user_isolation`, including reads of an existing store. A non-isolated or +unverifiable store is rejected; creation arguments are not treated as proof of +an existing store's configuration. Storage authorization failures are surfaced +rather than selecting an anonymous/shared partition. + +The hosted default namespace uses the stable agent GUID (or agent name when a +GUID is unavailable), not the process or hosted-session ID. The host drains +its default native handler before shutdown callbacks and closes its default +store afterward. Explicit stores and existing handlers remain caller-owned. + +To opt into persistence locally or choose a different named store: + +```python +from azure.ai.agentserver.a2a import A2AAgentServerHost, FoundryTaskStore + +store = FoundryTaskStore("my-agent/a2a-tasks") +host = A2AAgentServerHost(agent_card=card, agent_executor=executor, task_store=store) + + +@host.shutdown_handler +async def close_storage(): + await store.aclose() +``` + +Locally this uses Core's existing file-backed state store under +`AGENTSERVER_STATE_ROOT` (default `~/.agentserver`). Hosted operation uses the +Foundry storage service and its identity/permission requirements. Storage is +created lazily on the first save; reads do not create an empty store. +Storage failures are surfaced rather than falling back to hosted in-memory +storage. Store retention defaults to Core's 30 days and can be configured with +`item_ttl_seconds`. Large outputs should use artifact references where needed +to respect the state service's item-size limits. + +**Persistence is not execution recovery:** saving a Task marked `WORKING` does +not restart its executor. This feature does not create Core resilient tasks +or checkpoint workflows. Those remain separate application choices. + +Hosted persistence also requires the A2A gateway to deliver the platform +identity/brokered-context headers. A preview gateway may route native A2A +requests successfully while omitting this handoff. In that case the SDK rejects +storage access with a caller-context error; enabling the endpoint alone is not +proof that managed persistence is available. Do not disable isolation or supply +fabricated platform identity headers to work around a missing service handoff. + +See [the persistence example](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md) for a restart test. + +### Call a Foundry service from an A2A handler + +The host binds the inbound platform identity to a `ContextVar`. Forward the +call ID, in addition to normal authorization, when custom code calls a service +under `FOUNDRY_PROJECT_ENDPOINT`: + +```python +from azure.ai.agentserver.core import get_request_context + +headers = { + "Authorization": bearer, + **get_request_context().platform_headers(), +} +await client.post(foundry_service_url, headers=headers, json=payload) +``` + +`platform_headers()` forwards only `x-agent-foundry-call-id`. The inbound user +ID remains available as `get_request_context().user_id` for container-side +state partitioning and is not forwarded to Foundry services. + +An inbound A2A `contextId` is recorded as `gen_ai.conversation.id` and added to +the active baggage context so child model, tool, and framework spans can use the +same conversation correlation. + +### Host A2A and Invocations together + +Protocol hosts use cooperative inheritance, so both protocols can share one +process and port: + +```bash +pip install azure-ai-agentserver-a2a azure-ai-agentserver-invocations +``` + +```python +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.invocations import InvocationAgentServerHost + + +class MultiProtocolHost(A2AAgentServerHost, InvocationAgentServerHost): + pass + + +host = MultiProtocolHost(agent_card=card, request_handler=handler) + + +@host.invoke_handler +async def handle_invocation(request): + ... + + +host.run() +``` + +See the [sample setup and walkthroughs](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/agentserver/azure-ai-agentserver-a2a/samples) +for standalone and multi-protocol examples, a native local client, +and guidance on TaskStore durability and context reuse. + +## Scope + +This package hosts incoming A2A traffic. It does not provide an outbound A2A +client or intercept calls from the hosted agent to another agent. + +The Foundry service rewrites the externally returned agent card. Container +security schemes, signatures, provider/branding fields, internal URLs, and +unsupported capabilities are not exposed to callers. + +## Troubleshooting + +- **Agent Card discovery returns 404:** check the server port and use + `/a2a/.well-known/agent-card.json` for a local container. The public Foundry + Agent Card route is different; local sample clients do not authenticate to + that public endpoint. +- **A request returns `VERSION_NOT_SUPPORTED`:** use the native sample client, + or explicitly send `A2A-Version: 1.0` with JSON-RPC requests. +- **Task lookup fails after restarting a sample:** the examples use + `InMemoryTaskStore`. Its records do not survive a process restart. Reusing a + context ID does not restore a lost Task or application history. + +Core provides console logging for request failures and supports distributed +tracing through its configured exporters. See [distributed tracing](#distributed-tracing) +for the A2A span behavior. Do not include authorization headers or sensitive +message contents when sharing diagnostic logs. + +## Next steps + +- Follow the [sample setup and walkthroughs](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/agentserver/azure-ai-agentserver-a2a/samples) + to discover an Agent Card, send a message, and retrieve a Task locally. +- Implement your agent with the native [A2A Python SDK](https://github.com/a2aproject/a2a-python) + executor, request-handler, and TaskStore interfaces. +- Review the samples' state guidance before replacing in-memory storage, and + use [multi-protocol composition](#host-a2a-and-invocations-together) when + another Agent Server protocol must share the same process. + +## Contributing + +This project welcomes contributions and suggestions. Most contributions +require you to agree to a Contributor License Agreement (CLA) declaring that +you have the right to, and actually do, grant us the rights to use your +contribution. For details, visit [https://cla.microsoft.com](https://cla.microsoft.com). + +This project has adopted the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/api.md b/sdk/agentserver/azure-ai-agentserver-a2a/api.md new file mode 100644 index 000000000000..4d40191821b4 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/api.md @@ -0,0 +1,62 @@ +```py +namespace azure.ai.agentserver.a2a + + @experimental + class azure.ai.agentserver.a2a.A2AAgentServerHost(AgentServerHost): + property agent_card: AgentCard # Read-only + property request_handler: RequestHandler # Read-only + property routes: list[BaseRoute] # Read-only + + def __init__( + self, + *, + agent_card: AgentCard, + agent_executor: Optional[AgentExecutor] = ..., + card_modifier: Optional[Callable[[AgentCard], Awaitable[AgentCard]]] = ..., + context_builder: Optional[ServerCallContextBuilder] = ..., + request_handler: Optional[RequestHandler] = ..., + task_store: Optional[TaskStore] = ..., + **kwargs: Any + ) -> None: ... + + + @experimental + class azure.ai.agentserver.a2a.FoundryTaskStore(TaskStore): implements AsyncContextManager + + def __init__( + self, + name: str, + *, + credential: AsyncTokenCredential | None = ..., + endpoint: FoundryStorageEndpoint | str | None = ..., + item_ttl_seconds: int = DEFAULT_ITEM_TTL_SECONDS + ) -> None: ... + + async def aclose(self) -> None: ... + + async def delete( + self, + task_id: str, + context: ServerCallContext + ) -> None: ... + + async def get( + self, + task_id: str, + context: ServerCallContext + ) -> Task | None: ... + + async def list( + self, + params: ListTasksRequest, + context: ServerCallContext + ) -> ListTasksResponse: ... + + async def save( + self, + task: Task, + context: ServerCallContext + ) -> None: ... + + +``` \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-a2a/api.metadata.yml new file mode 100644 index 000000000000..ca3102434d96 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/api.metadata.yml @@ -0,0 +1,4 @@ +apiHash: 0c37561a27dc7230cabe3bcb9b77318be9c0be8489127a8d253e6af40e02a8cd +packageVersion: 1.0.0b1 +parserVersion: 0.3.31 +pythonVersion: 3.12.10 diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/__init__.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/__init__.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/__init__.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/__init__.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/__init__.py new file mode 100644 index 000000000000..68fbf07d27fa --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/__init__.py @@ -0,0 +1,13 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""A2A protocol host for Azure AI Hosted Agents.""" + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) + +from ._host import A2AAgentServerHost +from ._foundry_task_store import FoundryTaskStore +from ._version import VERSION + +__all__ = ["A2AAgentServerHost", "FoundryTaskStore"] +__version__ = VERSION diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_foundry_task_store.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_foundry_task_store.py new file mode 100644 index 000000000000..261c87478a49 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_foundry_task_store.py @@ -0,0 +1,286 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Native A2A task persistence using the shared Foundry state-store client.""" + +from __future__ import annotations + +from asyncio import Lock # pylint: disable=do-not-import-asyncio # Synchronization only, no transport sleeps. +import base64 +import hashlib +import json +import logging +import os + +from a2a.server.context import ServerCallContext +from a2a.server.tasks import InMemoryTaskStore, TaskStore +from a2a.types import ListTasksRequest, ListTasksResponse, Task +from azure.core.credentials_async import AsyncTokenCredential + +from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.core._experimental import experimental +from azure.ai.agentserver.core.storage import ( + DEFAULT_ITEM_TTL_SECONDS, + FoundryStateStore, + FoundryStorageEndpoint, + FoundryStorageNotFoundError, + JSONObject, + StateStoreItem, +) + +logger = logging.getLogger(__name__) +_MAX_TAG_VALUE_LENGTH = 256 +_MAX_ITEM_KEY_LENGTH = 128 + + +@experimental +class FoundryTaskStore(TaskStore): + """Persist native A2A Tasks in one named Core state store. + + Task snapshots are serialized losslessly as protobuf data. Hosted caller + isolation is enforced by Foundry storage using the brokered request context. + Available platform user, native user, and tenant scopes are layered into keys. A hosted + request needs brokered context or a usable caller identity, and every + operation verifies that the backing store enables server-side isolation. + + Locally, Core's file-backed state-store implementation is used. Persisting + task records does not restart interrupted executors or create resilient tasks. + + :param name: Stable store name, scoped to the owning agent. + :type name: str + :keyword credential: Async credential; defaults to Core's managed credential. + :paramtype credential: ~azure.core.credentials_async.AsyncTokenCredential or None + :keyword endpoint: Foundry storage or project endpoint; defaults to the environment. + :paramtype endpoint: ~azure.ai.agentserver.core.storage.FoundryStorageEndpoint or str or None + :keyword item_ttl_seconds: Retention inherited by newly created store items. + :paramtype item_ttl_seconds: int + """ + + def __init__( + self, + name: str, + *, + credential: AsyncTokenCredential | None = None, + endpoint: FoundryStorageEndpoint | str | None = None, + item_ttl_seconds: int = DEFAULT_ITEM_TTL_SECONDS, + ) -> None: + if not name: + raise ValueError("name must be a non-empty string") + self._name = name + self._credential = credential + self._endpoint = endpoint + self._item_ttl_seconds = item_ttl_seconds + self._reader: FoundryStateStore | None = None + self._writer: FoundryStateStore | None = None + self._lock = Lock() + self._closed = False + + @staticmethod + def _owner(context: ServerCallContext) -> str: + platform = get_request_context() + hosted = bool(os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT")) + if hosted and not (platform.user_id or platform.call_id or context.user.is_authenticated): + raise PermissionError("Hosted A2A task persistence requires caller identity or brokered context") + identity = json.dumps( + [platform.user_id, context.tenant, context.user.user_name], + ensure_ascii=True, + separators=(",", ":"), + ) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + + @staticmethod + def _key(task_id: str, owner: str) -> str: + if not task_id: + raise ValueError("task_id must be a non-empty string") + encoded = base64.urlsafe_b64encode(task_id.encode("utf-8")).decode("ascii").rstrip("=") + key = f"{owner}:{encoded}" + if len(key) > _MAX_ITEM_KEY_LENGTH: + return hashlib.sha256(f"{owner}:{task_id}".encode("utf-8")).hexdigest() + return key + + async def _store(self, *, create: bool = False) -> FoundryStateStore: + async with self._lock: + if self._closed: + raise RuntimeError("FoundryTaskStore is closed") + if self._writer is not None: + return self._writer + if create: + self._writer = await FoundryStateStore.get_or_create( + self._name, + credential=self._credential, + endpoint=self._endpoint, + user_isolation=True, + item_ttl_seconds=self._item_ttl_seconds, + ) + return self._writer + if self._reader is None: + self._reader = FoundryStateStore( + self._name, + credential=self._credential, + endpoint=self._endpoint, + user_isolation=True, + item_ttl_seconds=self._item_ttl_seconds, + ) + return self._reader + + @staticmethod + async def _verify_isolation(store: FoundryStateStore, *, missing_ok: bool = False) -> bool: + try: + descriptor = await store.get() + except FoundryStorageNotFoundError: + if missing_ok: + return False + raise + if getattr(descriptor, "user_isolation", None) is not True: + raise PermissionError("A2A task persistence requires a user-isolated backing store") + return True + + @classmethod + def _deserialize(cls, item: StateStoreItem, owner: str) -> Task: + return cls._decode_task(item.value.get("task"), item.key, owner) + + @classmethod + def _decode_task(cls, encoded: object, key: str, owner: str) -> Task: + if not isinstance(encoded, str): + raise ValueError("Stored A2A task payload must contain serialized task data") + task = Task() + task.ParseFromString(base64.b64decode(encoded, validate=True)) + if key != cls._key(task.id, owner): + raise ValueError("Stored A2A task identity does not match its storage key") + return task + + async def save(self, task: Task, context: ServerCallContext) -> None: + """Save a complete native Task snapshot for the current caller. + + :param task: Task to create or replace. + :type task: ~a2a.types.Task + :param context: Native caller context. + :type context: ~a2a.server.context.ServerCallContext + """ + owner = self._owner(context) + key = self._key(task.id, owner) + value: JSONObject = {"task": base64.b64encode(task.SerializeToString()).decode("ascii")} + summary = Task(id=task.id, context_id=task.context_id) + if task.HasField("status"): + summary.status.state = task.status.state + if task.status.HasField("timestamp"): + summary.status.timestamp.CopyFrom(task.status.timestamp) + tags = {"a2a_owner": owner} + encoded_summary = base64.b64encode(summary.SerializeToString()).decode("ascii") + if len(encoded_summary) <= _MAX_TAG_VALUE_LENGTH: + tags["a2a_list"] = encoded_summary + store = await self._store(create=True) + await self._verify_isolation(store) + await store.set_item( + key, + value, + tags=tags, + ) + + async def get(self, task_id: str, context: ServerCallContext) -> Task | None: + """Read a caller-scoped Task, or return ``None`` when absent. + + :param task_id: Native task identifier. + :type task_id: str + :param context: Native caller context. + :type context: ~a2a.server.context.ServerCallContext + :return: The stored Task, if present. + :rtype: ~a2a.types.Task or None + """ + owner = self._owner(context) + store = await self._store() + if not await self._verify_isolation(store, missing_ok=True): + return None + try: + item = await store.get_item(self._key(task_id, owner)) + except FoundryStorageNotFoundError: + return None + return None if item is None else self._deserialize(item, owner) + + async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse: + """List caller-scoped Tasks using the native SDK's filtering and paging. + + :param params: Native task-list filters and pagination. + :type params: ~a2a.types.ListTasksRequest + :param context: Native caller context. + :type context: ~a2a.server.context.ServerCallContext + :return: Native task-list response. + :rtype: ~a2a.types.ListTasksResponse + """ + owner = self._owner(context) + store = await self._store() + view = InMemoryTaskStore() + if not await self._verify_isolation(store, missing_ok=True): + return await view.list(params, context) + after = None + full_tasks: dict[str, Task] = {} + while True: + try: + page = await store.list_keys(tags={"a2a_owner": owner}, limit=100, after=after) + except FoundryStorageNotFoundError: + break + for key in page.keys: + tags = key.tags or {} + if "a2a_list" in tags: + summary = self._decode_task(tags["a2a_list"], key.key, owner) + else: + item = await store.get_item(key.key) + if item is None: + continue + summary = self._deserialize(item, owner) + full_tasks[summary.id] = summary + await view.save(summary, context) + if not page.has_more: + break + if page.last_id is None or page.last_id == after: + raise ValueError("Foundry state store returned an invalid pagination cursor") + after = page.last_id + result = await view.list(params, context) + tasks = [] + for summary in result.tasks: + if summary.id in full_tasks: + tasks.append(full_tasks[summary.id]) + else: + item = await store.get_item(self._key(summary.id, owner)) + if item is not None: + tasks.append(self._deserialize(item, owner)) + del result.tasks[:] + result.tasks.extend(tasks) + return result + + async def delete(self, task_id: str, context: ServerCallContext) -> None: + """Delete a caller-scoped Task without affecting other callers. + + :param task_id: Native task identifier. + :type task_id: str + :param context: Native caller context. + :type context: ~a2a.server.context.ServerCallContext + """ + owner = self._owner(context) + store = await self._store() + if not await self._verify_isolation(store, missing_ok=True): + logger.warning("Attempted to delete a missing A2A task") + return + try: + await store.delete_item(self._key(task_id, owner)) + except FoundryStorageNotFoundError: + logger.warning("Attempted to delete a missing A2A task") + + async def aclose(self) -> None: + """Close owned storage clients, without closing a supplied credential.""" + async with self._lock: + if self._closed: + return + self._closed = True + try: + if self._writer is not None: + await self._writer.aclose() + finally: + if self._reader is not None and self._reader is not self._writer: + await self._reader.aclose() + + async def __aenter__(self) -> FoundryTaskStore: + return self + + async def __aexit__(self, *args: object) -> None: + await self.aclose() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_host.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_host.py new file mode 100644 index 000000000000..c6902f1639a2 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_host.py @@ -0,0 +1,323 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""A2A protocol host for Azure AI Hosted Agents.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any, MutableMapping, Optional + +from starlette.datastructures import Headers +from starlette.middleware import Middleware +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.types import ASGIApp, Receive, Scope, Send + +from a2a.server.agent_execution import AgentExecutor +from a2a.server.request_handlers import DefaultRequestHandler, RequestHandler +from a2a.server.tasks import InMemoryTaskStore, TaskStore +from a2a.server.routes import ( + ServerCallContextBuilder, + create_agent_card_routes, + create_jsonrpc_routes, +) +from a2a.types import AgentCard + +from azure.ai.agentserver.core import ( + AgentConfig, + AgentServerHost, + FoundryAgentRequestContext, + build_server_version, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.core._experimental import experimental +from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module + ERROR_DETAIL, + ERROR_SOURCE, + FOUNDRY_CALL_ID, + MAX_ERROR_DETAIL_LENGTH, + PLATFORM_ERROR_TAG, + USER_ID, +) + +from ._tracing import trace_jsonrpc_endpoint +from ._foundry_task_store import FoundryTaskStore +from ._protocol import ProtocolContextBuilder +from ._version import VERSION as _A2A_VERSION + +_A2A_RPC_PATH = "/a2a" +_A2A_FOUNDRY_COMPAT_RPC_PATH = "/invocations/a2a" +_A2A_AGENT_CARD_PATH = "/a2a/.well-known/agent-card.json" +_A2A_FOUNDRY_COMPAT_AGENT_CARD_PATH = _A2A_FOUNDRY_COMPAT_RPC_PATH + "/.well-known/agent-card.json" +_A2A_PATHS = frozenset( + ( + _A2A_RPC_PATH, + _A2A_FOUNDRY_COMPAT_RPC_PATH, + _A2A_AGENT_CARD_PATH, + _A2A_FOUNDRY_COMPAT_AGENT_CARD_PATH, + ) +) +_ERROR_SOURCE_USER = "user" +_ERROR_SOURCE_UPSTREAM = "upstream" +_ERROR_SOURCE_PLATFORM = "platform" + +logger = logging.getLogger(__name__) + + +class _A2APlatformContextMiddleware: + """Bind Foundry platform headers while a native A2A route is executing.""" + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("path") not in _A2A_PATHS: + await self._app(scope, receive, send) + return + + headers = Headers(scope=scope) + token = set_request_context( + FoundryAgentRequestContext( + call_id=headers.get(FOUNDRY_CALL_ID), + user_id=headers.get(USER_ID), + ) + ) + try: + await self._app(scope, receive, send) + finally: + reset_request_context(token) + + +class _A2ATransportErrorMiddleware: + """Classify transport-level A2A HTTP errors with platform error headers.""" + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("path") not in _A2A_PATHS: + await self._app(scope, receive, send) + return + + async def send_with_error_source(message: MutableMapping[str, Any]) -> None: + if message["type"] == "http.response.start": + status = int(message["status"]) + if status >= 400: + headers = list(message.get("headers", [])) + if not any(name.lower() == ERROR_SOURCE.encode() for name, _ in headers): + source = _ERROR_SOURCE_USER if status < 500 else _ERROR_SOURCE_UPSTREAM + headers.append((ERROR_SOURCE.encode(), source.encode())) + message = {**message, "headers": headers} + await send(message) + + await self._app(scope, receive, send_with_error_source) + + +class _A2AExceptionMiddleware: + """Convert route exceptions before core adds shared response headers.""" + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + response_started = False + + async def send_with_response_started(message: MutableMapping[str, Any]) -> None: + nonlocal response_started + if message["type"] == "http.response.start": + response_started = True + await send(message) + + try: + await self._app(scope, receive, send_with_response_started) + except Exception as exc: # pylint: disable=broad-exception-caught + if response_started: + raise + + source = _ERROR_SOURCE_UPSTREAM + headers = {ERROR_SOURCE: source} + if getattr(exc, PLATFORM_ERROR_TAG, False) is True: + source = _ERROR_SOURCE_PLATFORM + detail = f"{type(exc).__name__}: {exc}" + detail = "".join(character for character in detail if 32 <= ord(character) <= 126) + headers = { + ERROR_SOURCE: source, + ERROR_DETAIL: detail[:MAX_ERROR_DETAIL_LENGTH], + } + + logger.exception("Unhandled A2A transport error") + response = JSONResponse( + {"detail": "Internal server error"}, + status_code=500, + headers=headers, + ) + await response(scope, receive, send) + + +def _get_a2a_routes( + agent_card: AgentCard, + request_handler: RequestHandler, + *, + card_modifier: Optional[Callable[[AgentCard], Awaitable[AgentCard]]] = None, + context_builder: Optional[ServerCallContextBuilder] = None, + config: Optional[AgentConfig] = None, +) -> list[Route]: + jsonrpc_routes = create_jsonrpc_routes( + request_handler=request_handler, + rpc_url=_A2A_RPC_PATH, + context_builder=ProtocolContextBuilder(context_builder), + enable_v0_3_compat=False, + ) + jsonrpc_route = jsonrpc_routes[0] + traced_endpoint = trace_jsonrpc_endpoint(jsonrpc_route.endpoint, agent_card, config=config) + agent_card_routes = create_agent_card_routes( + agent_card=agent_card, + card_modifier=card_modifier, + card_url=_A2A_AGENT_CARD_PATH, + ) + agent_card_route = agent_card_routes[0] + routes = [ + Route( + _A2A_RPC_PATH, + traced_endpoint, + methods=["POST"], + name=jsonrpc_route.name, + ), + Route( + _A2A_FOUNDRY_COMPAT_RPC_PATH, + traced_endpoint, + methods=["POST"], + name=f"{jsonrpc_route.name}-foundry-compat", + ), + *agent_card_routes, + Route( + _A2A_FOUNDRY_COMPAT_AGENT_CARD_PATH, + agent_card_route.endpoint, + methods=agent_card_route.methods, + name=f"{agent_card_route.name}-foundry-compat", + include_in_schema=False, + ), + ] + return [ + Route( + route.path, + route.endpoint, + methods=route.methods, + name=route.name, + include_in_schema=route.include_in_schema, + middleware=[Middleware(_A2AExceptionMiddleware)], + ) + for route in routes + ] + + +@experimental +class A2AAgentServerHost(AgentServerHost): + """Host a native A2A SDK handler in an Azure AI Hosted Agent container. + + The host registers the native SDK's JSON-RPC and public agent-card routes + at Foundry's canonical container paths. It also supplies the shared Agent + Server readiness endpoint, lifecycle, telemetry, and request-scoped + Foundry identity context. + + :param agent_card: Native A2A v1.0 agent card to serve to Foundry. + :type agent_card: ~a2a.types.AgentCard + :param request_handler: Existing native handler, whose storage is preserved. + Mutually exclusive with ``agent_executor`` and ``task_store``. + :type request_handler: ~a2a.server.request_handlers.RequestHandler or None + :keyword agent_executor: Native executor used to build a default handler. + Supply exactly one of this or ``request_handler``. + :paramtype agent_executor: ~a2a.server.agent_execution.AgentExecutor or None + :keyword task_store: Explicit native task store for the built handler. + Defaults to Foundry persistence when hosted, otherwise native in-memory storage. + Caller-supplied stores remain caller-owned. + :paramtype task_store: ~a2a.server.tasks.TaskStore or None + :param card_modifier: Optional async callback applied before serving the agent card. + :type card_modifier: Optional[Callable[[AgentCard], Awaitable[AgentCard]]] + :param context_builder: Optional native A2A server call-context builder. + :type context_builder: Optional[~a2a.server.routes.ServerCallContextBuilder] + """ + + def __init__( + self, + *, + agent_card: AgentCard, + request_handler: Optional[RequestHandler] = None, + agent_executor: Optional[AgentExecutor] = None, + task_store: Optional[TaskStore] = None, + card_modifier: Optional[Callable[[AgentCard], Awaitable[AgentCard]]] = None, + context_builder: Optional[ServerCallContextBuilder] = None, + **kwargs: Any, + ) -> None: + if (request_handler is None) == (agent_executor is None): + raise ValueError("Supply exactly one of request_handler or agent_executor") + if request_handler is not None and task_store is not None: + raise ValueError("Configure storage on the supplied request_handler instead of passing task_store") + self._agent_card = agent_card + self._owned_task_store: Optional[FoundryTaskStore] = None + self._owned_request_handler: Optional[DefaultRequestHandler] = None + existing_routes = list(kwargs.pop("routes", None) or []) + + super().__init__(routes=existing_routes, **kwargs) + if request_handler is None: + if agent_executor is None: + raise ValueError("agent_executor is required to build a request handler") + if task_store is None: + if self.config.is_hosted: + agent_identity = self.config.agent_guid or self.config.agent_name + if not agent_identity: + raise ValueError("Hosted default task storage requires a Foundry agent identity") + self._owned_task_store = FoundryTaskStore(f"a2a/tasks/{agent_identity}") + task_store = self._owned_task_store + else: + task_store = InMemoryTaskStore() + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=task_store, + agent_card=agent_card, + ) + self._owned_request_handler = request_handler + self._request_handler = request_handler + a2a_routes = _get_a2a_routes( + agent_card, + request_handler, + card_modifier=card_modifier, + context_builder=context_builder, + config=self.config, + ) + self.routes[len(existing_routes) : len(existing_routes)] = a2a_routes + self.add_middleware(_A2APlatformContextMiddleware) + self.add_middleware(_A2ATransportErrorMiddleware) + self.register_server_version(build_server_version("azure-ai-agentserver-a2a", _A2A_VERSION)) + + async def _dispatch_shutdown(self) -> None: + try: + if self._owned_request_handler is not None: + await self._owned_request_handler.aclose() + finally: + try: + await super()._dispatch_shutdown() + finally: + if self._owned_task_store is not None: + await self._owned_task_store.aclose() + + @property + def agent_card(self) -> AgentCard: + """Return the native A2A agent card hosted by this server. + + :return: The native A2A agent card. + :rtype: ~a2a.types.AgentCard + """ + return self._agent_card + + @property + def request_handler(self) -> RequestHandler: + """Return the native A2A request handler hosted by this server. + + :return: The native A2A request handler. + :rtype: ~a2a.server.request_handlers.RequestHandler + """ + return self._request_handler diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_protocol.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_protocol.py new file mode 100644 index 000000000000..f8cdf8e3b584 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_protocol.py @@ -0,0 +1,46 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Protocol selection shared by native request dispatch and tracing.""" + +from a2a.server.context import ServerCallContext +from a2a.server.routes import DefaultServerCallContextBuilder, ServerCallContextBuilder +from a2a.utils.errors import InvalidParamsError, VersionNotSupportedError +from starlette.requests import Request + +_SUPPORTED_VERSION = "1.0" +_VERSION_HEADER = "a2a-version" + + +def resolve_protocol_version(request: Request) -> str: + versions = { + value.strip() + for value in [ + *request.headers.getlist(_VERSION_HEADER), + *(value for key, value in request.query_params.multi_items() if key.lower() == _VERSION_HEADER), + ] + if value.strip() + } + if len(versions) > 1: + raise InvalidParamsError(message="Conflicting A2A-Version selectors") + selected = next(iter(versions), "0.3") + if selected != _SUPPORTED_VERSION: + raise VersionNotSupportedError(message=f"A2A version '{selected}' is not supported. Expected '1.0'.") + return _SUPPORTED_VERSION + + +class ProtocolContextBuilder(ServerCallContextBuilder): + """Validate selection and supply the effective version to native dispatch.""" + + def __init__(self, builder: ServerCallContextBuilder | None) -> None: + self._builder = builder if builder is not None else DefaultServerCallContextBuilder() + + def build(self, request: Request) -> ServerCallContext: + version = resolve_protocol_version(request) + context = self._builder.build(request) + headers = { + key: value for key, value in context.state.get("headers", {}).items() if key.lower() != _VERSION_HEADER + } + headers[_VERSION_HEADER] = version + context.state["headers"] = headers + return context diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_tracing.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_tracing.py new file mode 100644 index 000000000000..d8016bc16b04 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_tracing.py @@ -0,0 +1,274 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Request-level A2A tracing using Core's shared span lifecycle helpers.""" + +from __future__ import annotations + +import asyncio # pylint: disable=do-not-import-asyncio +import json +import re +from collections.abc import Awaitable, Callable +from typing import Any, Optional + +from a2a.types import AgentCard +from a2a.utils.errors import A2AError +from opentelemetry import baggage, context, trace +from opentelemetry.trace import SpanKind, StatusCode +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Message, Receive, Scope, Send + +from azure.ai.agentserver.core import AgentConfig, detach_context, end_span, schedule_flush_spans + +from ._version import VERSION +from ._protocol import resolve_protocol_version + +_INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.A2A" +_HOST_INSTRUMENTATION_SCOPE = "azure.ai.agentserver.a2a" +_BAGGAGE_CONVERSATION_ID = "azure.ai.agentserver.conversation_id" +_MAX_RESPONSE_BYTES = 1024 * 1024 +_SSE_BOUNDARY = re.compile(rb"\r?\n\r?\n") + + +class _TracedResponse(Response): + """Trace endpoint execution and response transmission under one lifecycle.""" + + def __init__( + self, + endpoint: Callable[[Request], Awaitable[Response]], + request: Request, + attributes: dict[str, Any], + config: Optional[AgentConfig], + ) -> None: + super().__init__() + self._endpoint, self._request, self._attributes = endpoint, request, attributes + self._span: trace.Span = trace.INVALID_SPAN + self._config = config + self._invocation: Optional[trace.Span] = None + self._known = "a2a.task.id" in attributes + self._buffer = b"" + self._discarding = False + + def observe_payload(self, payload: dict[str, Any]) -> None: + if self._known or not isinstance(payload.get("result"), dict): + return + result = payload["result"] + method = self._attributes["a2a.method.name"] + if method in {"GetTask", "CancelTask"}: + task_id = _nonempty_string(result.get("id")) + elif method in {"SendMessage", "SendStreamingMessage", "SubscribeToTask"}: + task_id = None + for name in ("task", "message", "statusUpdate", "artifactUpdate"): + value = result.get(name) + if isinstance(value, dict): + task_id = _nonempty_string(value.get("id" if name == "task" else "taskId")) + if task_id: + break + else: + return + if task_id: + self._span.set_attribute("a2a.task.id", task_id) + if self._invocation is not None: + self._invocation.set_attribute("a2a.task.id", task_id) + self._known = True + + def observe_chunk(self, body: bytes) -> None: + if self._known: + return + self._buffer += body + while boundary := _SSE_BOUNDARY.search(self._buffer): + event, self._buffer = self._buffer[: boundary.start()], self._buffer[boundary.end() :] + if not self._discarding and len(event) <= _MAX_RESPONSE_BYTES: + data = b"\n".join(line[5:].lstrip(b" ") for line in event.splitlines() if line.startswith(b"data:")) + self.observe_payload(_json_object(data)) + self._discarding = False + if self._known: + self._buffer = b"" + return + if len(self._buffer) > _MAX_RESPONSE_BYTES: + # Drop oversized events, retaining only enough bytes to recognize a split boundary. + self._buffer, self._discarding = self._buffer[-3:], True + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + attributes = self._attributes + self._invocation = _start_hosted_invocation(self._config, attributes) + parent_context = ( + trace.set_span_in_context(self._invocation) if self._invocation is not None else context.get_current() + ) + tracer = trace.get_tracer(_INSTRUMENTATION_SCOPE, VERSION) + span = tracer.start_span( + attributes["a2a.method.name"], context=parent_context, kind=SpanKind.SERVER, attributes=attributes + ) + if "gen_ai.conversation.id" in attributes: + for request_span in (span, self._invocation): + if request_span is not None: + request_span.set_attribute("gen_ai.conversation.id", attributes["gen_ai.conversation.id"]) + self._span = span + operation_context = trace.set_span_in_context(span, parent_context) + if "gen_ai.conversation.id" in attributes: + operation_context = baggage.set_baggage( + _BAGGAGE_CONVERSATION_ID, attributes["gen_ai.conversation.id"], context=operation_context + ) + token = context.attach(operation_context) + + async def observe_send(message: Message) -> None: + if message["type"] == "http.response.body": + self.observe_chunk(message.get("body", b"")) + await send(message) + + error: Optional[BaseException] = None + try: + response = await self._endpoint(self._request) + self._record_response(response) + is_sse = response.headers.get("content-type", "").startswith("text/event-stream") + await response(scope, receive, observe_send if is_sse and not self._known else send) + except BaseException as exc: + error = exc + raise + finally: + detach_context(token) + error = None if isinstance(error, asyncio.CancelledError) else error + end_span(span, exc=error) + end_span(self._invocation, exc=error) + schedule_flush_spans() + + def _record_response(self, response: Response) -> None: + error_type = None + if response.status_code >= 400: + error_type = str(response.status_code) + else: + # JSON-RPC may fail with HTTP 200. Inspect only a buffered unary body here. + body = getattr(response, "body", None) + if isinstance(body, bytes) and 0 < len(body) <= _MAX_RESPONSE_BYTES: + payload = _json_object(body) + self.observe_payload(payload) + error = payload.get("error") + if isinstance(error, dict) and error.get("code") is not None: + error_type = str(error["code"]) + if error_type is not None: + for span in (self._span, self._invocation): + if span is not None: + span.set_attribute("error.type", error_type) + span.set_status(StatusCode.ERROR) + + +def _start_hosted_invocation(config: Optional[AgentConfig], attributes: dict[str, Any]) -> Optional[trace.Span]: + if ( + not config + or not config.is_hosted + or attributes["a2a.method.name"] not in {"SendMessage", "SendStreamingMessage"} + ): + return None + hosted = { + **attributes, + "gen_ai.operation.name": "invoke_agent", + "gen_ai.system": "azure.ai.agentserver", + } + # Deployment identity belongs to the hosted invocation, not the native Agent Card. + for key, value in ( + ("gen_ai.agent.name", config.agent_name), + ("gen_ai.agent.version", config.agent_version), + ("gen_ai.agent.id", config.agent_id), + ("microsoft.foundry.project.id", config.project_id), + ("microsoft.session.id", config.session_id), + ): + hosted.pop(key, None) + if value: + hosted[key] = value + return trace.get_tracer(_HOST_INSTRUMENTATION_SCOPE, VERSION).start_span( + f"invoke_agent {config.agent_name}", attributes=hosted + ) + + +def trace_jsonrpc_endpoint( + endpoint: Callable[[Request], Awaitable[Response]], + agent_card: AgentCard, + *, + config: Optional[AgentConfig] = None, +) -> Callable[[Request], Awaitable[Response]]: + """Trace native A2A operations and correlate returned Task IDs. + + :param endpoint: Native A2A JSON-RPC route endpoint. + :type endpoint: Callable[[Request], Awaitable[Response]] + :param agent_card: Agent Card metadata to attach to operation spans. + :type agent_card: AgentCard + :keyword config: Resolved host configuration for deployment-aware invocation spans. + :paramtype config: ~azure.ai.agentserver.core.AgentConfig or None + :return: Endpoint that emits one span per request with an A2A method. + :rtype: Callable[[Request], Awaitable[Response]] + """ + + async def traced_endpoint(request: Request) -> Response: + attributes = await _request_attributes(request) + if attributes is None: + return await endpoint(request) + + for name, value in ( + ("gen_ai.agent.name", agent_card.name), + ("gen_ai.agent.description", agent_card.description), + ("gen_ai.agent.version", agent_card.version), + ): + if value: + attributes[name] = value + + for source in ("server", "client"): + address = request.scope.get(source) + if address: + attributes[f"{source}.address"] = address[0] + if address[1] is not None: + attributes[f"{source}.port"] = address[1] + + return _TracedResponse(endpoint, request, attributes, config) + + return traced_endpoint + + +async def _request_attributes(request: Request) -> Optional[dict[str, Any]]: + payload = _json_object(await request.body()) + if not isinstance(payload.get("method"), str): + return None + + method = payload["method"] + params = payload.get("params") + params = params if isinstance(params, dict) else {} + message = params.get("message") + message = message if isinstance(message, dict) else {} + attributes: dict[str, Any] = { + "a2a.method.name": method, + } + try: + attributes["a2a.protocol.version"] = resolve_protocol_version(request) + except A2AError: + pass # The native dispatcher returns the selection error; no version was processed. + for name, value in ( + ("a2a.message.id", message.get("messageId")), + ("a2a.tenant", params.get("tenant")), + ("gen_ai.conversation.id", _nonempty_string(message.get("contextId")) or params.get("contextId")), + ( + "a2a.task.id", + _nonempty_string(message.get("taskId")) + or _nonempty_string(params.get("taskId")) + or (params.get("id") if method in {"GetTask", "CancelTask", "SubscribeToTask"} else None), + ), + ): + if _nonempty_string(value): + attributes[name] = value + references = message.get("referenceTaskIds") + if isinstance(references, list): + references = [value for value in references if _nonempty_string(value)] + if references: + attributes["a2a.message.reference_task_ids"] = references + return attributes + + +def _nonempty_string(value: object) -> Optional[str]: + return value if isinstance(value, str) and value else None + + +def _json_object(body: bytes) -> dict[str, Any]: + try: + value = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_version.py b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_version.py new file mode 100644 index 000000000000..67d209a8cafd --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/_version.py @@ -0,0 +1,5 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +VERSION = "1.0.0b1" diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/py.typed b/sdk/agentserver/azure-ai-agentserver-a2a/azure/ai/agentserver/a2a/py.typed new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-a2a/dev_requirements.txt new file mode 100644 index 000000000000..2483b9730f9e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/dev_requirements.txt @@ -0,0 +1,9 @@ +# keep in sync with pyproject.toml#dependency-groups.dev +-e ../../../eng/tools/azure-sdk-tools +-e ../azure-ai-agentserver-core +-e ../azure-ai-agentserver-invocations +a2a-sdk[http-server]>=1.2.3,<2.0.0 +httpx2>=2.12.0,<3.0.0 +pytest>=8.3.5,<10.0.0 +pytest-asyncio>=0.24.0,<2.0.0 +opentelemetry-sdk>=1.43.0,<2.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/mypy.ini b/sdk/agentserver/azure-ai-agentserver-a2a/mypy.ini new file mode 100644 index 000000000000..fc644a935149 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +explicit_package_bases = True diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-a2a/pyproject.toml new file mode 100644 index 000000000000..0b91d421c332 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "azure-ai-agentserver-a2a" +dynamic = ["version", "readme"] +description = "A2A protocol host for Azure AI Hosted Agents" +requires-python = ">=3.10" +authors = [ + { name = "Microsoft Corporation", email = "azpysdkhelp@microsoft.com" }, +] +license = "MIT" +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", +] +keywords = ["azure", "azure sdk", "agent", "agentserver", "a2a"] + +dependencies = [ + "a2a-sdk[http-server]>=1.2.3,<2.0.0", + # The SDK dev-version rewriter matches canonical requirement ordering. + "azure-ai-agentserver-core<3.0.0,>=2.3.1b1", + "opentelemetry-api>=1.43.0,<2.0.0", + "starlette>=0.45.0,<2.0.0", +] + +[dependency-groups] +# keep in sync with dev_requirements.txt +dev = [ + "a2a-sdk[http-server]>=1.2.3,<2.0.0", + "azure-ai-agentserver-invocations>=1.2.0,<2.0.0", + "azure-sdk-tools==0.0.0", + "httpx2>=2.12.0,<3.0.0", + "opentelemetry-sdk>=1.43.0,<2.0.0", + "pytest-asyncio>=0.24.0,<2.0.0", + "pytest>=8.3.5,<10.0.0", +] + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python" + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = [ + "tests*", + "samples*", + "doc*", + "azure", + "azure.ai", + "azure.ai.agentserver", +] + +[tool.setuptools.dynamic] +version = { attr = "azure.ai.agentserver.a2a._version.VERSION" } +readme = { file = ["README.md"], content-type = "text/markdown" } + +[tool.setuptools.package-data] +"azure.ai.agentserver.a2a" = ["py.typed"] + +[tool.azure-sdk-conda] +in_bundle = false + +[tool.azure-sdk-build] +analyze_python_version = "3.11" +breaking = false +mypy = true +pyright = true +verifytypes = false +latestdependency = false +mindependency = false +pylint = true +type_check_samples = false +apistub = true + +[tool.uv.sources] +azure-ai-agentserver-core = { path = "../azure-ai-agentserver-core", editable = true } +azure-ai-agentserver-invocations = { path = "../azure-ai-agentserver-invocations", editable = true } +azure-sdk-tools = { path = "../../../eng/tools/azure-sdk-tools" } diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/pyrightconfig.json b/sdk/agentserver/azure-ai-agentserver-a2a/pyrightconfig.json new file mode 100644 index 000000000000..661972bcdab3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "reportOptionalMemberAccess": "warning", + "reportArgumentType": "warning", + "reportAttributeAccessIssue": "warning", + "reportMissingImports": "warning", + "reportGeneralTypeIssues": "warning", + "reportReturnType": "warning", + "extraPaths": [ + "../azure-ai-agentserver-core", + "../azure-ai-agentserver-invocations" + ], + "exclude": [ + "**/samples/**" + ] +} diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/01-hello-world/hello_world.py b/sdk/agentserver/azure-ai-agentserver-a2a/samples/01-hello-world/hello_world.py new file mode 100644 index 000000000000..26d4871f4e92 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/01-hello-world/hello_world.py @@ -0,0 +1,83 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Host a native A2A v1.0 hello-world agent. + +From the samples directory, run ``python 01-hello-world\\hello_world.py`` +and then ``python sample_client.py`` in another terminal. + +Local task storage is in memory; hosted default storage uses Foundry. +See the samples README for setup, state limitations, and hosted-agent guidance. +""" + +import os + +os.environ.setdefault("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED", "false") + +from a2a.helpers import new_task_from_user_message, new_text_message, new_text_part +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, TaskState +from a2a.utils.errors import TaskNotCancelableError + +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.core import AgentConfig + + +class HelloWorldExecutor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.current_task + if task is None: + if context.message is None: + raise ValueError("A2A request did not contain a message") + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task.id, + context_id=task.context_id, + ) + await updater.add_artifact(parts=[new_text_part(text="Hello from Foundry", media_type="text/plain")]) + await updater.update_status( + state=TaskState.TASK_STATE_COMPLETED, + message=new_text_message("Done"), + ) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise TaskNotCancelableError(message="Cancellation is not supported by this greeting sample") + + +agent_url = os.environ.get("A2A_AGENT_URL", f"http://localhost:{AgentConfig.from_env().port}/a2a") +card = AgentCard( + name="Hello World Agent", + description="Returns a greeting", + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=agent_url, + ) + ], + skills=[ + AgentSkill( + id="hello_world", + name="Hello world", + description="Returns a greeting", + input_modes=["text/plain"], + output_modes=["text/plain"], + tags=["hello"], + examples=["Say hello"], + ) + ], +) +host = A2AAgentServerHost(agent_card=card, agent_executor=HelloWorldExecutor()) + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/multi_protocol.py b/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/multi_protocol.py new file mode 100644 index 000000000000..7bc6023cd508 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/multi_protocol.py @@ -0,0 +1,86 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Host A2A v1.0 and Invocations on one server. + +From the samples directory, run ``python 02-multi-protocol\\multi_protocol.py``. +Use ``python sample_client.py`` for A2A, or POST JSON to ``/invocations``. + +Local task storage is in memory; hosted default storage uses Foundry. +See the samples README for dependency setup and request examples. +""" + +import os + +os.environ.setdefault("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED", "false") + +from a2a.helpers import new_task_from_user_message, new_text_message, new_text_part +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, TaskState +from a2a.utils.errors import TaskNotCancelableError +from starlette.requests import Request +from starlette.responses import JSONResponse + +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.core import AgentConfig +from azure.ai.agentserver.invocations import InvocationAgentServerHost + + +class HelloWorldExecutor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.current_task + if task is None: + if context.message is None: + raise ValueError("A2A request did not contain a message") + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task.id, + context_id=task.context_id, + ) + await updater.add_artifact(parts=[new_text_part(text="Hello over A2A", media_type="text/plain")]) + await updater.update_status( + state=TaskState.TASK_STATE_COMPLETED, + message=new_text_message("Done"), + ) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise TaskNotCancelableError(message="Cancellation is not supported by this greeting sample") + + +class MultiProtocolHost(A2AAgentServerHost, InvocationAgentServerHost): + pass + + +agent_url = os.environ.get("A2A_AGENT_URL", f"http://localhost:{AgentConfig.from_env().port}/a2a") +card = AgentCard( + name="Multi-protocol Agent", + description="Accepts A2A and Invocations requests", + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=agent_url, + ) + ], + skills=[], +) +host = MultiProtocolHost(agent_card=card, agent_executor=HelloWorldExecutor()) + + +@host.invoke_handler +async def handle_invocation(_request: Request) -> JSONResponse: + return JSONResponse({"message": "Hello over Invocations"}) + + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/requirements.txt b/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/requirements.txt new file mode 100644 index 000000000000..12023fd4d7a5 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-a2a +azure-ai-agentserver-invocations diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md b/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md new file mode 100644 index 000000000000..db1bd5017fa3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md @@ -0,0 +1,101 @@ +# Persist A2A Tasks across server restarts + +This example uses the existing generic Core state store to persist native A2A +Tasks. No LLM, subscription, or API key is needed for the local demonstration. + +## Install + +From the parent `samples` directory, follow the [source setup](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/README.md#setup). +Its requirements install the A2A and Core packages from sibling source folders. + +## 1. Start the persistent agent + +From that same `samples` directory: + +```powershell +$env:AGENTSERVER_STATE_ROOT = Join-Path $env:TEMP "a2a-state-store-demo" +.\.venv\Scripts\python 04-state-store\state_store.py +``` + +Locally, the sample explicitly supplies `FoundryTaskStore` to opt out of the +normal in-memory local default. Core stores the data in files under the chosen +state root. + +## 2. Create a Task + +In another terminal, from `samples`: + +```powershell +.\.venv\Scripts\python sample_client.py --message "remember this" +``` + +The client prints: + +```text +Task: +Context: +State: TASK_STATE_COMPLETED +Artifact: Persisted: remember this +``` + +Copy the Task ID. + +## 3. Stop and restart the server + +Stop the first process with Ctrl+C. Start it again with the **same** +`AGENTSERVER_STATE_ROOT` and store name: + +```powershell +.\.venv\Scripts\python 04-state-store\state_store.py +``` + +Retrieve the earlier Task from the client terminal: + +```powershell +.\.venv\Scripts\python sample_client.py --get-task "" +``` + +The ID, context, completed state, and `Persisted: remember this` artifact remain +available even though the original server process is gone. The standard local +hello-world example does not retain Tasks across this restart. + +## Hosted behavior and ownership + +In a Foundry-hosted container, the sample leaves storage selection to the host: +the default is a Core-backed store scoped to the stable agent identity. +No physical session ID is used as the storage namespace. Access is isolated +by Foundry's configured caller scope, with native user/tenant scoping layered +into item keys. A trusted brokered call ID can be used when the container does +not receive a raw user-ID header. Every operation verifies server-side store +isolation; requests without usable identity/context fail rather than selecting +shared anonymous state. Core's storage identity and permission requirements +still apply. + +If the preview A2A gateway does not forward platform identity or brokered +context, hosted storage fails closed. The gateway handoff must be fixed or +deployed before this sample can prove managed persistence across containers. +Local file-backed persistence does not validate that service integration. + +To verify managed persistence, create two fresh hosted sessions pinned to the +same agent version and use the same authenticated caller. Send a message through +session A, then call `GetTask` and `ListTasks` through session B before sending +any message there. Compare the completed Task's ID, context, status, history, +and artifacts. Confirm session B's response session ID and distinct container +startup/runtime identity; reusing session A only tests retrieval in the original +process. JSON responses can differ in omitted protobuf defaults, so compare +decoded native Tasks when checking snapshot equality. + +For local persistence, this sample owns its explicitly supplied store and closes +it through the host's shutdown handler. The host owns and closes the automatically +selected hosted store. + +Native Task snapshots include status, history, and artifacts. Normal retention +and state-item size limits apply; large outputs should use artifact references. + +## This is not crash recovery of work + +This example proves **Task data persistence**, not that an unfinished operation +continues automatically. It does not create Core resilient tasks, replay an +executor, or restore a Python call stack. An interrupted `WORKING` Task needs +application-designed recovery. The completed Task used here can be fetched +without re-running its executor. diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/state_store.py b/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/state_store.py new file mode 100644 index 000000000000..4bab80239e14 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/state_store.py @@ -0,0 +1,85 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Persist native A2A Tasks using Core's generic state store. + +Run this example, send a message with sample_client.py, stop the server, and +restart it with the same AGENTSERVER_STATE_ROOT. GetTask still returns the saved +completed Task. Local storage is file-backed; hosted storage uses Foundry. +This persists task snapshots, not unfinished executor execution. +""" + +import os + +os.environ.setdefault("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED", "false") + +from a2a.helpers import get_message_text, new_task_from_user_message, new_text_message +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part, TaskState +from a2a.utils.errors import TaskNotCancelableError + +from azure.ai.agentserver.a2a import A2AAgentServerHost, FoundryTaskStore +from azure.ai.agentserver.core import AgentConfig + + +class PersistentEchoExecutor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + if context.message is None: + raise ValueError("A2A request did not contain a message") + task = context.current_task + if task is None: + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue=event_queue, task_id=task.id, context_id=task.context_id) + await updater.add_artifact( + parts=[Part(text=f"Persisted: {get_message_text(context.message)}", media_type="text/plain")] + ) + await updater.update_status( + state=TaskState.TASK_STATE_COMPLETED, + message=new_text_message("Saved in the state store"), + ) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise TaskNotCancelableError(message="Cancellation is not supported by this persistence sample") + + +config = AgentConfig.from_env() +agent_url = os.environ.get("A2A_AGENT_URL", f"http://localhost:{config.port}/a2a") +card = AgentCard( + name="State Store Agent", + description="Persists native A2A Tasks across server restarts", + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(), + supported_interfaces=[AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url=agent_url)], + skills=[ + AgentSkill( + id="persistent_echo", + name="Persistent echo", + description="Saves an echo artifact that can be retrieved after restart", + tags=["state-store"], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], +) +task_store = None if config.is_hosted else FoundryTaskStore("a2a-state-store-demo") +host = A2AAgentServerHost( + agent_card=card, + agent_executor=PersistentEchoExecutor(), + task_store=task_store, +) + + +@host.shutdown_handler +async def close_storage() -> None: + if task_store is not None: + await task_store.aclose() + + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/README.md b/sdk/agentserver/azure-ai-agentserver-a2a/samples/README.md new file mode 100644 index 000000000000..51e12d6fcba2 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/README.md @@ -0,0 +1,156 @@ +--- +page_type: sample +languages: +- python +products: +- azure +name: azure-ai-agentserver-a2a samples for Python +description: Native A2A hosting, multi-protocol composition, and persistent Tasks. +--- + +# Azure AI Agent Server A2A samples + +These examples use the official `a2a-sdk` for Agent Cards, executors, Tasks, and +artifacts. Agent Server adds the container hosting contract. No LLM, Azure +subscription, or API key is required for these deterministic local examples. + +## Setup + +Use Python 3.10 or later. Run these commands from this `samples` directory: + +```powershell +python -m venv .venv +.\.venv\Scripts\python -m pip install -r requirements.txt +``` + +The requirements install A2A, core, and Invocations from sibling source +directories. Invocations is needed only for the multi-protocol example; it is +not a runtime dependency of the A2A package. Keep the current source checkouts +together, since the preview requires the core tracing fix in 2.3.1b1. +The updated A2A package requires native `a2a-sdk>=1.2.3` for caller authorization +on active-task cache lookups. + +Only run one sample at a time on the default port, 8088. Stop the server before +switching examples. + +## Samples + +| Example | Start from this directory | What to observe | +|---------|---------------------------|-----------------| +| [Hello world](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/01-hello-world/hello_world.py) | `.\.venv\Scripts\python 01-hello-world\hello_world.py` | A completed Task containing `Hello from Foundry`. | +| [Multi-protocol](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/02-multi-protocol/multi_protocol.py) | `.\.venv\Scripts\python 02-multi-protocol\multi_protocol.py` | A2A and Invocations on the same server and port. | +| [State store](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md) | `.\.venv\Scripts\python 04-state-store\state_store.py` | A completed Task can still be fetched after stopping and restarting the server. | + +Every sample also exposes `GET /readiness` and the native Agent Card at +`GET /a2a/.well-known/agent-card.json`. + +## Send a message + +With the hello-world or multi-protocol server running, open another terminal +in this directory and run the [native client](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/sample_client.py): + +```powershell +.\.venv\Scripts\python sample_client.py --message "hello" +``` + +The client discovers the Agent Card and uses native A2A types. Successful output +includes the opaque Task and context IDs, `TASK_STATE_COMPLETED`, and the +greeting artifact. The client explicitly selects A2A wire version 1.0. + +For direct JSON-RPC requests: + +```powershell +$request = @{ + jsonrpc = "2.0" + id = "request-1" + method = "SendMessage" + params = @{ + message = @{ + messageId = "message-1" + role = "ROLE_USER" + parts = @(@{ text = "hello" }) + } + configuration = @{ returnImmediately = $false } + } +} | ConvertTo-Json -Depth 10 + +Invoke-RestMethod -Uri "http://localhost:8088/a2a" -Method Post ` + -ContentType "application/json" -Headers @{ "A2A-Version" = "1.0" } ` + -Body $request +``` + +## Retrieve state and reuse a context + +Keep the same server process running. Copy the IDs printed by the client: + +```powershell +.\.venv\Scripts\python sample_client.py --get-task "" +.\.venv\Scripts\python sample_client.py --context-id "" --message "hello again" +``` + +`GetTask` retrieves the stored Task, including its completed state and artifact. +Sending another message with the previous `contextId` creates a new greeting +Task in that context; it is not a request to resume a completed Task. + +### What the examples do and do not store + +- **Task ID:** identifies a native A2A Task and is used for local task lookup. +- **Context ID:** correlates related A2A messages/Tasks. Treat it as opaque. +- **TaskStore:** the hello-world and multi-protocol examples select native in-memory storage + locally, so restarting the server loses their Tasks. Executor-based hosts + default to Foundry-backed storage when hosted. The state-store example explicitly + uses `FoundryTaskStore` locally so Core's file-backed store can demonstrate + persistence across restarts. +- **Application memory:** reusing a context ID does not automatically create + conversational memory, restore model history, or replay business operations. + The executors here always return a fixed greeting. +- **Cancellation:** the examples do not implement cancellation; an attempted + executor cancellation raises the native `TaskNotCancelableError`. + +Custom stores and existing native request handlers are still supported and +remain caller-owned. Persistence alone does not resume executor work after a +process crash: recovery, cancellation, and application history need explicit +application design. See [the state-store example's restart walkthrough](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/samples/04-state-store/README.md). + +These are **local container** examples, not a claim that every native A2A +method is available through Foundry's public endpoint. When deployed, the +platform-returned Agent Card is authoritative. Foundry routes hosted sessions +using its documented context/isolation rules; an A2A context ID is not the +same thing as a platform session ID, and process-local state must not be assumed +to survive routing to a different container. + +## Exercise the Invocations endpoint + +With the multi-protocol server running: + +```powershell +Invoke-RestMethod -Uri "http://localhost:8088/invocations" -Method Post ` + -ContentType "application/json" -Body '{"message":"hello"}' +``` + +Expected output: `{"message":"Hello over Invocations"}`. + +## Change the local port or advertised URL + +The samples derive their default Agent Card URL from core's `PORT` setting: + +```powershell +$env:PORT = "8090" +.\.venv\Scripts\python 01-hello-world\hello_world.py +``` + +In the client terminal: + +```powershell +.\.venv\Scripts\python sample_client.py --endpoint "http://localhost:8090" +``` + +Set `A2A_AGENT_URL` separately if a local proxy requires a different advertised +JSON-RPC URL. It changes the card, not the server's listening port. Native +clients send to the URL advertised by the card, so it must be reachable. + +The examples disable the native SDK's generic instrumentation before importing +`a2a`, avoiding duplicate spans alongside Agent Server's semantic operation +spans. This can be overridden by explicitly setting +`OTEL_INSTRUMENTATION_A2A_SDK_ENABLED`. See the [package tracing guidance](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-a2a/README.md#distributed-tracing) +for exporter configuration and the development-stage A2A convention. diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/requirements.txt b/sdk/agentserver/azure-ai-agentserver-a2a/samples/requirements.txt new file mode 100644 index 000000000000..2ab6e058fcdc --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/requirements.txt @@ -0,0 +1,6 @@ +# Run pip install from THIS samples directory so the editable paths resolve. +# Core 2.3.1b1 and A2A are installed from the sibling source checkouts for previews. +-e ../../azure-ai-agentserver-core +-e .. +-e ../../azure-ai-agentserver-invocations +httpx2>=2.12.0,<3.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/samples/sample_client.py b/sdk/agentserver/azure-ai-agentserver-a2a/samples/sample_client.py new file mode 100644 index 000000000000..a4c9e9795dc4 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/samples/sample_client.py @@ -0,0 +1,70 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Exercise the local A2A samples with the official native client. + +Run ``python sample_client.py --help`` for sending and task lookup. +This client targets the local container routes, not the authenticated public +Foundry endpoint. +""" + +import argparse +import asyncio + +import httpx2 as httpx +from a2a.client import A2ACardResolver, ClientConfig, create_client +from a2a.helpers import get_artifact_text, get_message_text, new_text_message +from a2a.types import GetTaskRequest, Role, SendMessageRequest, Task, TaskState + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--endpoint", default="http://localhost:8088", help="Local server base URL, without /a2a.") + parser.add_argument("--message", default="hello") + parser.add_argument("--context-id", help="Reuse an opaque context ID from a previous response.") + parser.add_argument("--get-task", metavar="TASK_ID", help="Retrieve a task instead of sending a message.") + return parser.parse_args(argv) + + +def _print_task(task: Task) -> None: + print(f"Task: {task.id}", flush=True) + print(f"Context: {task.context_id}", flush=True) + print(f"State: {TaskState.Name(task.status.state)}", flush=True) + for artifact in task.artifacts: + print(f"Artifact: {get_artifact_text(artifact, delimiter='')}", flush=True) + + +async def _run(args: argparse.Namespace) -> None: + async with httpx.AsyncClient(headers={"A2A-Version": "1.0"}, timeout=60.0) as http_client: + card = await A2ACardResolver(http_client, args.endpoint).get_agent_card( + relative_card_path="a2a/.well-known/agent-card.json", + ) + client = await create_client(card, ClientConfig(streaming=False, httpx_client=http_client)) + try: + if args.get_task: + _print_task(await client.get_task(GetTaskRequest(id=args.get_task))) + return + + request = SendMessageRequest( + message=new_text_message( + args.message, + role=Role.ROLE_USER, + context_id=args.context_id, + ) + ) + completed = False + async for response in client.send_message(request): + if response.HasField("task"): + _print_task(response.task) + completed = response.task.status.state == TaskState.TASK_STATE_COMPLETED + elif response.HasField("message"): + print(f"Message: {get_message_text(response.message)}", flush=True) + completed = True + if not completed: + raise RuntimeError("The greeting sample did not return a completed task or inline message") + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(_run(_parse_args())) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/conftest.py new file mode 100644 index 000000000000..025e4c035a53 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/conftest.py @@ -0,0 +1,43 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Shared fixtures for A2A protocol tests.""" + +import os + +os.environ["OTEL_INSTRUMENTATION_A2A_SDK_ENABLED"] = "false" + +import pytest_asyncio +from httpx2 import ASGITransport, AsyncClient +from a2a.server.request_handlers import DefaultRequestHandler + +from azure.ai.agentserver.a2a import A2AAgentServerHost + + +@pytest_asyncio.fixture +async def client_factory(): + """Create an in-process HTTP client for an ASGI application.""" + + clients = [] + hosts = {} + + def create(app): + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) + clients.append(client) + if isinstance(app, A2AAgentServerHost): + hosts[id(app)] = app + return client + + try: + yield create + finally: + for client in reversed(clients): + await client.aclose() + for host in reversed(list(hosts.values())): + handler = host.request_handler + if isinstance(handler, DefaultRequestHandler) and host._owned_request_handler is None: + await handler.aclose() + await host._dispatch_shutdown() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/fixtures/a2a-contract.json b/sdk/agentserver/azure-ai-agentserver-a2a/tests/fixtures/a2a-contract.json new file mode 100644 index 000000000000..396cb951c2fe --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/fixtures/a2a-contract.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "protocol_version_cases": [ + {"name": "missing", "headers": [], "query": [], "error_code": -32009}, + {"name": "empty-header", "headers": [["A2A-Version", ""]], "query": [], "error_code": -32009}, + {"name": "header", "headers": [["A2A-Version", "1.0"]], "query": [], "effective_version": "1.0"}, + {"name": "header-case", "headers": [["a2a-version", "1.0"]], "query": [], "effective_version": "1.0"}, + {"name": "query", "headers": [], "query": [["a2a-version", "1.0"]], "effective_version": "1.0"}, + {"name": "query-case", "headers": [], "query": [["A2A-Version", "1.0"]], "effective_version": "1.0"}, + {"name": "matching", "headers": [["A2A-Version", "1.0"]], "query": [["a2a-version", "1.0"]], "effective_version": "1.0"}, + {"name": "conflict", "headers": [["A2A-Version", "1.0"]], "query": [["a2a-version", "2.0"]], "error_code": -32602}, + {"name": "query-conflict", "headers": [], "query": [["a2a-version", "1.0"], ["A2A-Version", "2.0"]], "error_code": -32602}, + {"name": "header-conflict", "headers": [["A2A-Version", "1.0"], ["A2A-Version", "2.0"]], "query": [], "error_code": -32602}, + {"name": "unsupported-major", "headers": [["A2A-Version", "2.0"]], "query": [], "error_code": -32009}, + {"name": "unsupported-minor", "headers": [["A2A-Version", "1.1"]], "query": [], "error_code": -32009}, + {"name": "unsupported-query", "headers": [], "query": [["a2a-version", "2.0"]], "error_code": -32009}, + {"name": "patch-not-major-minor", "headers": [["A2A-Version", "1.0.9"]], "query": [], "error_code": -32009}, + {"name": "malformed", "headers": [["A2A-Version", "invalid"]], "query": [], "error_code": -32009} + ], + "storage_tag_cases": [ + {"name": "key-at-128", "task_id_length": 47, "context_id_length": 1, "summary_tag": true, "key_chars": 128}, + {"name": "key-over-128", "task_id_length": 48, "context_id_length": 1, "summary_tag": true, "key_chars": 64}, + {"name": "below-bound", "task_id_length": 171, "context_id_length": 1, "summary_tag": true}, + {"name": "at-256", "task_id_length": 176, "context_id_length": 1, "summary_tag": true, "summary_chars": 256}, + {"name": "above-256", "task_id_length": 177, "context_id_length": 1, "summary_tag": false}, + {"name": "long-identifiers", "task_id_length": 128, "context_id_length": 128, "summary_tag": false} + ], + "list_cases": [ + { + "name": "inclusive-boundary-and-ties", + "tasks": [ + {"id": "a", "timestamp_ns": 10000000001}, + {"id": "c", "timestamp_ns": 10000000001}, + {"id": "b", "timestamp_ns": 10000000001}, + {"id": "before", "timestamp_ns": 10000000000} + ], + "cutoff_ns": 10000000001, + "page_size": 2, + "expected_pages": [["c", "b"], ["a"]], + "expected_total_sizes": [3, 3] + }, + { + "name": "delete-returned-records", + "tasks": [ + {"id": "task-0", "timestamp_ns": 1}, + {"id": "task-1", "timestamp_ns": 2}, + {"id": "task-2", "timestamp_ns": 3}, + {"id": "task-3", "timestamp_ns": 4}, + {"id": "task-4", "timestamp_ns": 5} + ], + "page_size": 2, + "delete_after_first": ["task-4", "task-3"], + "expected_pages": [["task-4", "task-3"], ["task-2", "task-1"], ["task-0"]], + "expected_total_sizes": [5, 3, 3] + } + ] +} diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_active_task_isolation.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_active_task_isolation.py new file mode 100644 index 000000000000..a0810ddec6f5 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_active_task_isolation.py @@ -0,0 +1,155 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Regression coverage for native cached-task ownership (upstream issue #1159).""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from a2a.helpers import new_text_message +from a2a.server.agent_execution import AgentExecutor +from a2a.server.context import ServerCallContext +from a2a.types import ( + AgentCapabilities, + AgentCard, + Artifact, + CancelTaskRequest, + Part, + Role, + SendMessageConfiguration, + SendMessageRequest, + SubscribeToTaskRequest, + Task, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) +from a2a.utils.errors import TaskNotFoundError + +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + get_request_context, + reset_request_context, + set_request_context, +) + + +class _HoldingExecutor(AgentExecutor): + def __init__(self): + self.release = asyncio.Event() + self.finished = asyncio.Event() + self.cancellations = 0 + + async def execute(self, context, event_queue): + try: + await event_queue.enqueue_event( + Task( + id=context.task_id, + context_id=context.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + artifacts=[Artifact(artifact_id="private", parts=[Part(text="private result")])], + history=[context.message], + ) + ) + await self.release.wait() + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + context_id=context.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + ) + ) + finally: + self.finished.set() + + async def cancel(self, context, event_queue): + self.cancellations += 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["subscribe", "cancel"]) +@pytest.mark.parametrize("include_raw_user", [False, True]) +async def test_another_caller_cannot_access_cached_active_task(monkeypatch, operation, include_raw_user): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "isolated-agent") + monkeypatch.setenv("FOUNDRY_AGENT_ID", "isolated-agent-guid") + records = {} + backend = AsyncMock() + backend.get.return_value = SimpleNamespace(user_isolation=True) + callers = {"alice-call": "alice", "bob-call": "bob"} + + async def write(key, value, **kwargs): + records[(callers[get_request_context().call_id], key)] = SimpleNamespace(key=key, value=value) + + async def read(key, **kwargs): + return records.get((callers[get_request_context().call_id], key)) + + backend.set_item.side_effect = write + backend.get_item.side_effect = read + factory = Mock(return_value=backend) + factory.get_or_create = AsyncMock(return_value=backend) + monkeypatch.setattr("azure.ai.agentserver.a2a._foundry_task_store.FoundryStateStore", factory) + executor = _HoldingExecutor() + host = A2AAgentServerHost( + agent_card=AgentCard(name="isolated", version="1.0", capabilities=AgentCapabilities(streaming=True)), + agent_executor=executor, + configure_observability=None, + ) + handler = host.request_handler + context = ServerCallContext() + alice = FoundryAgentRequestContext(user_id="alice" if include_raw_user else None, call_id="alice-call") + token = set_request_context(alice) + try: + result = await asyncio.wait_for( + handler.on_message_send( + SendMessageRequest( + message=new_text_message("private input", role=Role.ROLE_USER), + configuration=SendMessageConfiguration(return_immediately=True), + ), + context, + ), + timeout=5, + ) + assert isinstance(result, Task) + task_id = result.id + finally: + reset_request_context(token) + + try: + token = set_request_context( + FoundryAgentRequestContext(user_id="bob" if include_raw_user else None, call_id="bob-call") + ) + try: + assert await host._owned_task_store.get(task_id, context) is None + if operation == "subscribe": + stream = handler.on_subscribe_to_task(SubscribeToTaskRequest(id=task_id), context) + try: + with pytest.raises(TaskNotFoundError): + await asyncio.wait_for(anext(stream), timeout=5) + finally: + await stream.aclose() + else: + with pytest.raises(TaskNotFoundError): + await asyncio.wait_for(handler.on_cancel_task(CancelTaskRequest(id=task_id), context), timeout=5) + assert executor.cancellations == 0 + finally: + reset_request_context(token) + + token = set_request_context(alice) + try: + stream = handler.on_subscribe_to_task(SubscribeToTaskRequest(id=task_id), context) + try: + owned = await asyncio.wait_for(anext(stream), timeout=5) + assert owned.artifacts[0].parts[0].text == "private result" + assert owned.history[0].parts[0].text == "private input" + finally: + await stream.aclose() + finally: + reset_request_context(token) + finally: + executor.release.set() + await asyncio.wait_for(executor.finished.wait(), timeout=5) + await host._dispatch_shutdown() diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_contract.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_contract.py new file mode 100644 index 000000000000..d9fd7b7ff752 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_contract.py @@ -0,0 +1,200 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Language-neutral request and storage contracts exercised through native APIs.""" + +import base64 +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from a2a.helpers import new_text_message +from a2a.server.agent_execution import AgentExecutor +from a2a.server.context import ServerCallContext +from a2a.server.routes import ServerCallContextBuilder +from a2a.types import AgentCard, ListTasksRequest, Task, TaskState, TaskStatus +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from azure.ai.agentserver.a2a import A2AAgentServerHost, FoundryTaskStore + +CONTRACT = json.loads((Path(__file__).parent / "fixtures" / "a2a-contract.json").read_text()) + + +class _Executor(AgentExecutor): + async def execute(self, context, event_queue): + await event_queue.enqueue_event(new_text_message("contract reply")) + + async def cancel(self, context, event_queue): + raise NotImplementedError + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/a2a", "/invocations/a2a"]) +@pytest.mark.parametrize("case", CONTRACT["protocol_version_cases"], ids=lambda case: case["name"]) +async def test_protocol_selection_matches_dispatch_and_tracing(client_factory, monkeypatch, path, case): + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + with patch("azure.ai.agentserver.a2a._tracing.trace.get_tracer", provider.get_tracer): + host = A2AAgentServerHost( + agent_card=AgentCard(name="contract", version="1"), + agent_executor=_Executor(), + configure_observability=None, + ) + try: + async with client_factory(host) as client: + response = await client.post( + path, + headers=case["headers"], + params=case["query"], + json={ + "jsonrpc": "2.0", + "id": case["name"], + "method": "SendMessage", + "params": { + "message": {"messageId": case["name"], "role": "ROLE_USER", "parts": [{"text": "test"}]}, + }, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["id"] == case["name"] + if "error_code" in case: + assert body["error"]["code"] == case["error_code"] + else: + assert body["result"]["message"]["parts"][0]["text"] == "contract reply" + spans = [ + span + for span in exporter.get_finished_spans() + if span.instrumentation_scope.name == "Azure.AI.AgentServer.A2A" + ] + assert len(spans) == 1 + assert spans[0].attributes.get("a2a.protocol.version") == case.get("effective_version") + if "error_code" in case: + assert spans[0].attributes["error.type"] == str(case["error_code"]) + finally: + await host._dispatch_shutdown() + provider.shutdown() + + +@pytest.mark.asyncio +async def test_query_version_preserves_custom_context_builder(client_factory, monkeypatch): + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + calls = [] + + class Builder(ServerCallContextBuilder): + def build(self, request): + context = ServerCallContext(state={"custom": "kept"}, tenant="tenant") + calls.append(context) + return context + + host = A2AAgentServerHost( + agent_card=AgentCard(name="contract", version="1"), + agent_executor=_Executor(), + context_builder=Builder(), + configure_observability=None, + ) + try: + async with client_factory(host) as client: + response = await client.post( + "/a2a?a2a-version=1.0", + json={ + "jsonrpc": "2.0", + "id": "custom", + "method": "SendMessage", + "params": { + "message": {"messageId": "custom", "role": "ROLE_USER", "parts": [{"text": "test"}]}, + }, + }, + ) + assert "result" in response.json() + assert len(calls) == 1 + assert calls[0].state["custom"] == "kept" + assert calls[0].state["headers"]["a2a-version"] == "1.0" + finally: + await host._dispatch_shutdown() + + +@pytest.fixture +def local_store_root(monkeypatch, tmp_path): + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path)) + + +def test_long_item_keys_retain_owner_scope_and_identifier_uniqueness(): + first = FoundryTaskStore._key("\u00e9" * 200, "a" * 64) + assert len(first) == 64 + assert first != FoundryTaskStore._key("\u00e9" * 200, "b" * 64) + assert first != FoundryTaskStore._key("\u00e9" * 199 + "x", "a" * 64) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", CONTRACT["storage_tag_cases"], ids=lambda case: case["name"]) +async def test_storage_tag_bound_preserves_full_task_and_listing(local_store_root, case): + task = Task( + id="t" * case["task_id_length"], + context_id="c" * case["context_id_length"], + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + ) + task.status.timestamp.FromNanoseconds(10000000001) + context = ServerCallContext() + async with FoundryTaskStore(case["name"]) as store: + backend = await store._store(create=True) + original_set = backend.set_item + + async def bounded_set(key, value, **kwargs): + assert len(key) <= 128 + assert all(len(value) <= 256 for value in kwargs["tags"].values()) + return await original_set(key, value, **kwargs) + + with patch.object(backend, "set_item", side_effect=bounded_set) as write: + await store.save(task, context) + tags = write.call_args.kwargs["tags"] + if "key_chars" in case: + assert len(write.call_args.args[0]) == case["key_chars"] + assert ("a2a_list" in tags) is case["summary_tag"] + if "summary_chars" in case: + assert len(tags["a2a_list"]) == case["summary_chars"] + assert await store.get(task.id, context) == task + assert list((await store.list(ListTasksRequest(), context)).tasks) == [task] + if not case["summary_tag"]: + decoded = Task.FromString( + base64.b64decode( + ( + await backend.get_item( + store._key(task.id, store._owner(context)), + ) + ).value["task"] + ) + ) + assert decoded == task + await store.delete(task.id, context) + assert await store.get(task.id, context) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", CONTRACT["list_cases"], ids=lambda case: case["name"]) +async def test_portable_task_list_contract(local_store_root, case): + context = ServerCallContext() + async with FoundryTaskStore(case["name"]) as store: + for item in case["tasks"]: + task = Task(id=item["id"], status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED)) + task.status.timestamp.FromNanoseconds(item["timestamp_ns"]) + await store.save(task, context) + request = ListTasksRequest(page_size=case["page_size"]) + if "cutoff_ns" in case: + request.status_timestamp_after.FromNanoseconds(case["cutoff_ns"]) + for index, expected in enumerate(case["expected_pages"]): + page = await store.list(request, context) + assert [task.id for task in page.tasks] == expected + assert page.page_size == case["page_size"] + assert page.total_size == case["expected_total_sizes"][index] + assert bool(page.next_page_token) == (index < len(case["expected_pages"]) - 1) + if index == 0: + for task_id in case.get("delete_after_first", []): + await store.delete(task_id, context) + request.page_token = page.next_page_token diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_error_source_classification.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_error_source_classification.py new file mode 100644 index 000000000000..e402e75c31f7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_error_source_classification.py @@ -0,0 +1,223 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for A2A transport-level error source classification.""" + +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import cast +from unittest.mock import patch + +import pytest +from a2a.server.request_handlers import RequestHandler +from a2a.types import AgentCapabilities, AgentCard, AgentInterface +from azure.ai.agentserver.core._platform_headers import ( + ERROR_DETAIL, + ERROR_SOURCE, + PLATFORM_ERROR_TAG, + REQUEST_ID, + SERVER_VERSION, +) +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from azure.ai.agentserver.a2a import A2AAgentServerHost + +_RPC_PATH = "/a2a" +_RPC_PATHS = [_RPC_PATH, "/invocations/a2a"] +_CARD_PATHS = ["/a2a/.well-known/agent-card.json", "/invocations/a2a/.well-known/agent-card.json"] + + +def _agent_card() -> AgentCard: + return AgentCard( + name="error-test", + description="error classification test agent", + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url="http://localhost:8088/a2a", + ) + ], + skills=[], + ) + + +def _host(endpoint: Callable[[Request], Awaitable[Response]]) -> A2AAgentServerHost: + with patch( + "azure.ai.agentserver.a2a._host.create_jsonrpc_routes", + return_value=[Route(_RPC_PATH, endpoint, methods=["POST"])], + ): + return A2AAgentServerHost( + agent_card=_agent_card(), + request_handler=cast(RequestHandler, object()), + configure_observability=None, + ) + + +def _assert_shared_headers(response, request_id: str) -> None: + assert response.headers[REQUEST_ID] == request_id + assert len(response.headers.get_list(REQUEST_ID)) == 1 + assert len(response.headers.get_list(SERVER_VERSION)) == 1 + assert "azure-ai-agentserver-core/" in response.headers[SERVER_VERSION] + assert "azure-ai-agentserver-a2a/" in response.headers[SERVER_VERSION] + + +@pytest.mark.asyncio +async def test_success_has_no_error_source(client_factory): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"ok": True}) + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH) + + assert response.status_code == 200 + assert ERROR_SOURCE not in response.headers + assert ERROR_DETAIL not in response.headers + + +@pytest.mark.asyncio +async def test_client_transport_error_is_classified_as_user(client_factory): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"detail": "invalid request"}, status_code=400) + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH) + + assert response.status_code == 400 + assert response.headers[ERROR_SOURCE] == "user" + assert ERROR_DETAIL not in response.headers + + +@pytest.mark.asyncio +async def test_server_transport_error_is_classified_as_upstream(client_factory): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"detail": "handler failed"}, status_code=500) + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH) + + assert response.status_code == 500 + assert response.headers[ERROR_SOURCE] == "upstream" + assert ERROR_DETAIL not in response.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _RPC_PATHS) +async def test_unhandled_exception_is_classified_as_upstream(client_factory, path): + async def endpoint(_request: Request) -> JSONResponse: + raise RuntimeError("handler failed") + + async with client_factory(_host(endpoint)) as client: + response = await client.post(path, headers={REQUEST_ID: "request-error"}) + + assert response.status_code == 500 + assert response.headers[ERROR_SOURCE] == "upstream" + assert ERROR_DETAIL not in response.headers + _assert_shared_headers(response, "request-error") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _RPC_PATHS) +async def test_platform_exception_includes_error_detail(client_factory, path): + async def endpoint(_request: Request) -> JSONResponse: + error = RuntimeError("storage unavailable") + setattr(error, PLATFORM_ERROR_TAG, True) + raise error + + async with client_factory(_host(endpoint)) as client: + response = await client.post(path, headers={REQUEST_ID: "request-platform-error"}) + + assert response.status_code == 500 + assert response.headers[ERROR_SOURCE] == "platform" + assert "storage unavailable" in response.headers[ERROR_DETAIL] + _assert_shared_headers(response, "request-platform-error") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _RPC_PATHS) +async def test_exception_response_preserves_trace_request_id(client_factory, path): + async def endpoint(_request: Request) -> JSONResponse: + raise RuntimeError("handler failed") + + trace_id = "0123456789abcdef0123456789abcdef" + async with client_factory(_host(endpoint)) as client: + response = await client.post( + path, + headers={ + REQUEST_ID: "client-request", + "traceparent": f"00-{trace_id}-0123456789abcdef-01", + }, + json={"jsonrpc": "2.0", "id": "request", "method": "GetTask", "params": {"id": "task"}}, + ) + + assert response.status_code == 500 + _assert_shared_headers(response, trace_id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _RPC_PATHS) +async def test_method_not_allowed_keeps_error_classification(client_factory, path): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"ok": True}) + + async with client_factory(_host(endpoint)) as client: + response = await client.get(path, headers={REQUEST_ID: "request-method"}) + + assert response.status_code == 405 + assert response.headers[ERROR_SOURCE] == "user" + _assert_shared_headers(response, "request-method") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _CARD_PATHS) +async def test_agent_card_exception_has_shared_headers(client_factory, path): + async def card_modifier(_card: AgentCard) -> AgentCard: + raise RuntimeError("card modifier failed") + + host = A2AAgentServerHost( + agent_card=_agent_card(), + request_handler=cast(RequestHandler, object()), + card_modifier=card_modifier, + configure_observability=None, + ) + async with client_factory(host) as client: + response = await client.get( + path, + headers={REQUEST_ID: "request-card"}, + ) + + assert response.status_code == 500 + assert response.headers[ERROR_SOURCE] == "upstream" + _assert_shared_headers(response, "request-card") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _CARD_PATHS) +async def test_agent_card_method_error_has_shared_headers(client_factory, path): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"ok": True}) + + async with client_factory(_host(endpoint)) as client: + response = await client.post(path, headers={REQUEST_ID: "card-method"}) + assert response.status_code == 405 + assert response.headers[ERROR_SOURCE] == "user" + _assert_shared_headers(response, "card-method") + + +@pytest.mark.asyncio +async def test_stream_exception_after_headers_is_not_converted(client_factory): + async def generate() -> AsyncIterator[bytes]: + yield b"started" + raise RuntimeError("stream failed") + + async def endpoint(_request: Request) -> StreamingResponse: + return StreamingResponse(generate()) + + async with client_factory(_host(endpoint)) as client: + with pytest.raises(RuntimeError, match="stream failed"): + await client.post(_RPC_PATH) diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_foundry_task_store.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_foundry_task_store.py new file mode 100644 index 000000000000..1e01a870eee4 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_foundry_task_store.py @@ -0,0 +1,456 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Persistence, native listing, and isolation for the Core-backed task store.""" + +import base64 +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from a2a.auth.user import User +from a2a.helpers import new_text_message +from a2a.server.context import ServerCallContext +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import Artifact, ListTasksRequest, Part, Task, TaskState, TaskStatus + +from azure.ai.agentserver.a2a import FoundryTaskStore +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + get_request_context, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.core.storage import FoundryStateStore, StateStoreItemKeyPage + + +class _User(User): + def __init__(self, name): + self.name = name + + @property + def is_authenticated(self): + return True + + @property + def user_name(self): + return self.name + + +@pytest.fixture(autouse=True) +def local_state(monkeypatch, tmp_path): + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path)) + token = set_request_context(FoundryAgentRequestContext()) + yield + reset_request_context(token) + + +def _task(task_id="task-1", *, context_id="conversation", state=TaskState.TASK_STATE_COMPLETED): + message = new_text_message("hello") + message.message_id = "message-1" + return Task( + id=task_id, + context_id=context_id, + status=TaskStatus(state=state), + artifacts=[Artifact(artifact_id="result", parts=[Part(text="saved answer")])], + history=[message], + ) + + +@pytest.mark.asyncio +async def test_roundtrip_survives_new_store_instance_and_preserves_snapshot(): + context = ServerCallContext() + task = _task() + task.metadata.update({"nested": {"count": 3, "enabled": True}}) + expected = Task() + expected.CopyFrom(task) + async with FoundryTaskStore("test") as store: + await store.save(task, context) + task.artifacts[0].parts[0].text = "mutated after saving" + loaded = await store.get("task-1", context) + assert loaded == expected + loaded.artifacts[0].parts[0].text = "mutated after reading" + assert await store.get("task-1", context) == expected + async with FoundryTaskStore("test") as restarted: + assert await restarted.get("task-1", context) == expected + + +@pytest.mark.asyncio +async def test_missing_reads_and_lists_do_not_create_store(tmp_path): + async with FoundryTaskStore("absent") as store: + assert await store.get("missing", ServerCallContext()) is None + assert not (await store.list(ListTasksRequest(), ServerCallContext())).tasks + assert not list(tmp_path.rglob("*.json")) + + +@pytest.mark.asyncio +async def test_save_replaces_and_delete_removes_task(): + context = ServerCallContext() + async with FoundryTaskStore("test") as store: + await store.save(_task(state=TaskState.TASK_STATE_WORKING), context) + await store.save(_task(), context) + assert (await store.get("task-1", context)).status.state == TaskState.TASK_STATE_COMPLETED + await store.delete("task-1", context) + assert await store.get("task-1", context) is None + await store.delete("task-1", context) + + +@pytest.mark.asyncio +async def test_native_users_and_tenants_are_isolated(): + alice = ServerCallContext(user=_User("alice"), tenant="tenant-1") + bob = ServerCallContext(user=_User("bob"), tenant="tenant-1") + other_tenant = ServerCallContext(user=_User("alice"), tenant="tenant-2") + async with FoundryTaskStore("test") as store: + await store.save(_task(), alice) + for other in (bob, other_tenant): + assert await store.get("task-1", other) is None + assert not (await store.list(ListTasksRequest(), other)).tasks + await store.delete("task-1", other) + assert await store.get("task-1", alice) == _task() + + +@pytest.mark.asyncio +async def test_platform_callers_are_isolated_with_anonymous_native_context(): + context = ServerCallContext() + async with FoundryTaskStore("test") as store: + token = set_request_context(FoundryAgentRequestContext(user_id="user-1")) + try: + await store.save(_task(), context) + finally: + reset_request_context(token) + token = set_request_context(FoundryAgentRequestContext(user_id="user-2")) + try: + assert await store.get("task-1", context) is None + assert not (await store.list(ListTasksRequest(), context)).tasks + finally: + reset_request_context(token) + token = set_request_context(FoundryAgentRequestContext(user_id="user-1")) + try: + assert await store.get("task-1", context) == _task() + finally: + reset_request_context(token) + + +@pytest.mark.asyncio +async def test_agents_using_different_stores_are_isolated(): + context = ServerCallContext() + async with FoundryTaskStore("agent-1") as first, FoundryTaskStore("agent-2") as second: + await first.save(_task(), context) + assert await second.get("task-1", context) is None + + +@pytest.mark.asyncio +async def test_listing_matches_native_filters_order_and_pagination_across_backend_pages(): + context = ServerCallContext(user=_User("alice")) + native = InMemoryTaskStore() + async with FoundryTaskStore("test") as store: + for index in range(105): + task = _task( + f"task-{index:03}", + context_id="even" if index % 2 == 0 else "odd", + state=TaskState.TASK_STATE_WORKING if index % 3 == 0 else TaskState.TASK_STATE_COMPLETED, + ) + task.status.timestamp.FromSeconds(index + 1) + await store.save(task, context) + await native.save(task, context) + request = ListTasksRequest(context_id="even", status=TaskState.TASK_STATE_COMPLETED, page_size=7) + request.status_timestamp_after.FromSeconds(10) + backend = await store._store() + with patch.object(backend, "get_item", wraps=backend.get_item) as read: + first = await store.list(request, context) + assert first == await native.list(request, context) + assert read.await_count == 7 + assert first.next_page_token + request.page_token = first.next_page_token + read.reset_mock() + assert await store.list(request, context) == await native.list(request, context) + assert read.await_count == 7 + + +@pytest.mark.asyncio +async def test_one_task_page_reads_only_one_full_snapshot_from_105_large_tasks(): + context = ServerCallContext() + native = InMemoryTaskStore() + async with FoundryTaskStore("test") as store: + for index in range(105): + task = _task(f"task-{index:03}") + task.artifacts[0].parts[0].text = "large artifact" * 1000 + task.history[0].parts[0].text = "large history" * 1000 + task.status.timestamp.FromNanoseconds(index * 1000000000 + 123) + await store.save(task, context) + await native.save(task, context) + backend = await store._store() + with patch.object(backend, "get_item", wraps=backend.get_item) as read: + request = ListTasksRequest(page_size=1) + for _ in range(2): + page = await store.list(request, context) + assert page == await native.list(request, context) + assert read.await_count == 1 + assert page.total_size == 105 + request.page_token = page.next_page_token + read.reset_mock() + + +@pytest.mark.asyncio +async def test_listing_reads_full_snapshot_when_summary_is_absent(): + context = ServerCallContext() + task = _task() + async with FoundryTaskStore("test") as store: + await store.save(task, context) + backend = await store._store() + owner = store._owner(context) + key = store._key(task.id, owner) + await backend.set_item( + key, + {"task": base64.b64encode(task.SerializeToString()).decode("ascii")}, + tags={"a2a_owner": owner}, + ) + with patch.object(backend, "get_item", wraps=backend.get_item) as read: + assert list((await store.list(ListTasksRequest(), context)).tasks) == [task] + assert read.await_count == 1 + + +@pytest.mark.asyncio +async def test_listing_metadata_tracks_updates_missing_timestamps_and_deletion(): + context = ServerCallContext() + native = InMemoryTaskStore() + async with FoundryTaskStore("test") as store: + for task in ( + _task("no-timestamp"), + _task("first", state=TaskState.TASK_STATE_WORKING), + _task("second"), + ): + if task.id != "no-timestamp": + task.status.timestamp.FromNanoseconds(1000000001) + await store.save(task, context) + await native.save(task, context) + updated = _task("first") + updated.status.timestamp.FromNanoseconds(2000000002) + await store.save(updated, context) + await native.save(updated, context) + await store.delete("second", context) + await native.delete("second", context) + request = ListTasksRequest(page_size=1, status=TaskState.TASK_STATE_COMPLETED) + page = await store.list(request, context) + assert page == await native.list(request, context) + request.page_token = page.next_page_token + assert await store.list(request, context) == await native.list(request, context) + + +@pytest.mark.asyncio +async def test_owner_tag_is_query_safe_and_identical_for_saving_and_listing(monkeypatch): + backend = AsyncMock() + backend.get.return_value = SimpleNamespace(user_isolation=True) + backend.list_keys.return_value = StateStoreItemKeyPage(keys=[]) + monkeypatch.setattr(FoundryStateStore, "get_or_create", AsyncMock(return_value=backend)) + context = ServerCallContext(user=_User("alice")) + async with FoundryTaskStore("test") as store: + await store.save(_task(), context) + tags = backend.set_item.call_args.kwargs["tags"] + assert tags["a2a_owner"] == store._owner(context) + assert all(key.isidentifier() for key in tags) + summary = Task.FromString(base64.b64decode(tags["a2a_list"])) + assert summary.id == "task-1" and summary.context_id == "conversation" + assert summary.status.state == TaskState.TASK_STATE_COMPLETED + assert not summary.history and not summary.artifacts + await store.list(ListTasksRequest(), context) + backend.list_keys.assert_awaited_once_with(tags={"a2a_owner": tags["a2a_owner"]}, limit=100, after=None) + + +@pytest.mark.asyncio +async def test_hosted_anonymous_operations_fail_before_accessing_storage(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + create = AsyncMock() + monkeypatch.setattr(FoundryStateStore, "get_or_create", create) + async with FoundryTaskStore("test") as store: + with pytest.raises(PermissionError, match="caller identity"): + await store.save(_task(), ServerCallContext()) + create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_hosted_caller_uses_core_user_isolation_and_retains_request_context(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + backend = AsyncMock() + backend.get.return_value = SimpleNamespace(user_isolation=True) + create = AsyncMock(return_value=backend) + monkeypatch.setattr(FoundryStateStore, "get_or_create", create) + token = set_request_context(FoundryAgentRequestContext(call_id="call-1", user_id="caller-1")) + try: + async with FoundryTaskStore("agent-store", item_ttl_seconds=600) as store: + await store.save(_task(), ServerCallContext()) + create.assert_awaited_once_with( + "agent-store", credential=None, endpoint=None, user_isolation=True, item_ttl_seconds=600 + ) + backend.set_item.assert_awaited_once() + backend.aclose.assert_awaited_once() + finally: + reset_request_context(token) + + +@pytest.mark.asyncio +async def test_brokered_callers_remain_isolated_without_raw_user_headers(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + callers = {"alice-1": "alice", "alice-2": "alice", "bob-1": "bob"} + records = {} + backend = AsyncMock() + + def caller(): + call_id = get_request_context().call_id + if call_id not in callers: + raise PermissionError("Unresolved brokered caller") + return callers[call_id] + + async def descriptor(): + caller() + return SimpleNamespace(user_isolation=True) + + async def write(key, value, **kwargs): + records[(caller(), key)] = SimpleNamespace(key=key, value=value, tags=kwargs["tags"]) + + async def read(key, **kwargs): + return records.get((caller(), key)) + + async def delete(key, **kwargs): + records.pop((caller(), key), None) + + async def keys(**kwargs): + return StateStoreItemKeyPage( + keys=[ + SimpleNamespace(key=key, id=key, tags=item.tags) + for (user, key), item in records.items() + if user == caller() + ] + ) + + backend.get.side_effect = descriptor + backend.set_item.side_effect = write + backend.get_item.side_effect = read + backend.delete_item.side_effect = delete + backend.list_keys.side_effect = keys + monkeypatch.setattr(FoundryStateStore, "get_or_create", AsyncMock(return_value=backend)) + context = ServerCallContext() + async with FoundryTaskStore("test") as store: + token = set_request_context(FoundryAgentRequestContext(call_id="alice-1")) + try: + await store.save(_task(), context) + finally: + reset_request_context(token) + token = set_request_context(FoundryAgentRequestContext(call_id="bob-1")) + try: + assert await store.get("task-1", context) is None + assert not (await store.list(ListTasksRequest(), context)).tasks + await store.delete("task-1", context) + own = _task() + own.artifacts[0].parts[0].text = "bob result" + await store.save(own, context) + assert await store.get("task-1", context) == own + finally: + reset_request_context(token) + token = set_request_context(FoundryAgentRequestContext(call_id="alice-2")) + try: + assert await store.get("task-1", context) == _task() + assert len((await store.list(ListTasksRequest(), context)).tasks) == 1 + finally: + reset_request_context(token) + token = set_request_context(FoundryAgentRequestContext(call_id="invalid")) + try: + with pytest.raises(PermissionError, match="Unresolved"): + await store.get("task-1", context) + finally: + reset_request_context(token) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["save", "get", "list", "delete"]) +async def test_nonisolated_backing_store_is_rejected_before_item_access(monkeypatch, operation): + backend = AsyncMock() + backend.get.return_value = SimpleNamespace(user_isolation=False) + factory = Mock(return_value=backend) + factory.get_or_create = AsyncMock(return_value=backend) + monkeypatch.setattr("azure.ai.agentserver.a2a._foundry_task_store.FoundryStateStore", factory) + async with FoundryTaskStore("test") as store: + with pytest.raises(PermissionError, match="user-isolated"): + if operation == "save": + await store.save(_task(), ServerCallContext()) + elif operation == "get": + await store.get("task-1", ServerCallContext()) + elif operation == "list": + await store.list(ListTasksRequest(), ServerCallContext()) + else: + await store.delete("task-1", ServerCallContext()) + backend.set_item.assert_not_awaited() + backend.get_item.assert_not_awaited() + backend.list_keys.assert_not_awaited() + backend.delete_item.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_backing_isolation_is_rechecked_after_configuration_changes(monkeypatch): + backend = AsyncMock() + backend.get.return_value = SimpleNamespace(user_isolation=True) + backend.get_item.return_value = None + monkeypatch.setattr("azure.ai.agentserver.a2a._foundry_task_store.FoundryStateStore", lambda *a, **k: backend) + async with FoundryTaskStore("test") as store: + assert await store.get("missing", ServerCallContext()) is None + backend.get.return_value = SimpleNamespace(user_isolation=False) + with pytest.raises(PermissionError, match="user-isolated"): + await store.get("missing", ServerCallContext()) + backend.get_item.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_storage_failure_is_propagated_without_memory_fallback(monkeypatch): + create = AsyncMock(side_effect=RuntimeError("storage unavailable")) + monkeypatch.setattr(FoundryStateStore, "get_or_create", create) + async with FoundryTaskStore("test") as store: + with pytest.raises(RuntimeError, match="storage unavailable"): + await store.save(_task(), ServerCallContext()) + create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_corrupt_record_does_not_return_another_task(): + context = ServerCallContext() + async with FoundryTaskStore("test") as store: + await store.save(_task(), context) + async with FoundryStateStore("test") as backend: + key = (await backend.list_keys()).keys[0].key + wrong = _task("another-task") + await backend.set_item(key, {"task": base64.b64encode(wrong.SerializeToString()).decode("ascii")}) + with pytest.raises(ValueError, match="identity"): + await store.get("task-1", context) + + +@pytest.mark.asyncio +async def test_corrupt_listing_metadata_does_not_read_another_task(): + context = ServerCallContext() + async with FoundryTaskStore("test") as store: + await store.save(_task(), context) + backend = await store._store() + owner = store._owner(context) + key = store._key("task-1", owner) + item = await backend.get_item(key) + tags = dict(item.tags) + tags["a2a_list"] = base64.b64encode(_task("another-task").SerializeToString()).decode("ascii") + await backend.set_item(key, item.value, tags=tags) + with patch.object(backend, "get_item", wraps=backend.get_item) as read: + with pytest.raises(ValueError, match="identity"): + await store.list(ListTasksRequest(), context) + read.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_close_is_idempotent_and_closed_store_rejects_operations(): + store = FoundryTaskStore("test") + await store.save(_task(), ServerCallContext()) + await store.aclose() + await store.aclose() + with pytest.raises(RuntimeError, match="closed"): + await store.get("task-1", ServerCallContext()) + + +def test_empty_store_name_is_rejected(): + with pytest.raises(ValueError, match="name"): + FoundryTaskStore("") diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_host_storage.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_host_storage.py new file mode 100644 index 000000000000..2d989972cf1d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_host_storage.py @@ -0,0 +1,141 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Defaults, overrides, and ownership for host-built native handlers.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from a2a.server.agent_execution import AgentExecutor +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCard + +from azure.ai.agentserver.a2a import A2AAgentServerHost, FoundryTaskStore + + +@pytest.mark.parametrize("public_api", [A2AAgentServerHost, FoundryTaskStore]) +def test_public_classes_are_marked_experimental(public_api): + assert "This is an experimental class" in public_api.__doc__ + assert "may change at any time" in public_api.__doc__ + assert "https://" not in public_api.__doc__ + + +class _Executor(AgentExecutor): + async def execute(self, context, event_queue): + raise NotImplementedError + + async def cancel(self, context, event_queue): + raise NotImplementedError + + +@pytest.fixture(autouse=True) +def local_host(monkeypatch): + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + + +def _host(**kwargs): + return A2AAgentServerHost( + agent_card=AgentCard(name="test", version="1.0.0"), + configure_observability=None, + **kwargs, + ) + + +def test_local_executor_defaults_to_native_memory(): + with patch("azure.ai.agentserver.a2a._host.DefaultRequestHandler", wraps=DefaultRequestHandler) as factory: + host = _host(agent_executor=_Executor()) + assert isinstance(factory.call_args.kwargs["task_store"], InMemoryTaskStore) + assert host._owned_task_store is None + + +def test_hosted_executor_defaults_to_agent_scoped_foundry_store(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "my-agent") + monkeypatch.setenv("FOUNDRY_AGENT_ID", "stable-agent-guid") + with patch("azure.ai.agentserver.a2a._host.DefaultRequestHandler", wraps=DefaultRequestHandler) as factory: + host = _host(agent_executor=_Executor()) + assert isinstance(factory.call_args.kwargs["task_store"], FoundryTaskStore) + assert host._owned_task_store._name == "a2a/tasks/stable-agent-guid" + + +@pytest.mark.parametrize("hosted", [False, True]) +def test_explicit_store_is_preserved(monkeypatch, hosted): + if hosted: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + store = InMemoryTaskStore() + with patch("azure.ai.agentserver.a2a._host.DefaultRequestHandler", wraps=DefaultRequestHandler) as factory: + host = _host(agent_executor=_Executor(), task_store=store) + assert factory.call_args.kwargs["task_store"] is store + assert host._owned_task_store is None + + +def test_hosted_default_requires_an_agent_namespace(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.delenv("FOUNDRY_AGENT_NAME", raising=False) + monkeypatch.delenv("FOUNDRY_AGENT_ID", raising=False) + with pytest.raises(ValueError, match="agent identity"): + _host(agent_executor=_Executor()) + + +def test_existing_handler_is_preserved(): + handler = DefaultRequestHandler( + agent_executor=_Executor(), task_store=InMemoryTaskStore(), agent_card=AgentCard(name="test") + ) + assert _host(request_handler=handler).request_handler is handler + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"request_handler": object(), "agent_executor": _Executor()}, + {"request_handler": object(), "task_store": InMemoryTaskStore()}, + ], +) +def test_ambiguous_or_missing_configuration_is_rejected(kwargs): + with pytest.raises(ValueError): + _host(**kwargs) + + +@pytest.mark.asyncio +async def test_shutdown_closes_host_owned_store_even_when_user_callback_fails(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "my-agent") + host = _host(agent_executor=_Executor()) + close = AsyncMock() + monkeypatch.setattr(host._owned_task_store, "aclose", close) + + @host.shutdown_handler + async def fail(): + raise RuntimeError("shutdown callback failed") + + with pytest.raises(RuntimeError, match="shutdown callback failed"): + await host._dispatch_shutdown() + close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_shutdown_does_not_close_caller_owned_store(): + store = FoundryTaskStore("caller-owned") + with patch.object(store, "aclose", new_callable=AsyncMock) as close: + host = _host(agent_executor=_Executor(), task_store=store) + await host._dispatch_shutdown() + close.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_owned_handler_drains_before_user_shutdown_callback(): + host = _host(agent_executor=_Executor()) + order = [] + + async def drain(): + order.append("handler") + + @host.shutdown_handler + async def close_resources(): + order.append("user") + + with patch.object(host._owned_request_handler, "aclose", side_effect=drain): + await host._dispatch_shutdown() + assert order == ["handler", "user"] diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_samples.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_samples.py new file mode 100644 index 000000000000..5b6b54c2f7f7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_samples.py @@ -0,0 +1,229 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Smoke tests for the actual runnable SDK samples and their native client.""" + +import importlib.util +import asyncio +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest +from a2a.client import A2ACardResolver, ClientConfig, create_client +from a2a.helpers import get_artifact_text, new_text_message +from a2a.types import GetTaskRequest, Role, SendMessageRequest, TaskState + +_SAMPLES = Path(__file__).resolve().parents[1] / "samples" +_EXAMPLES = [ + ("01-hello-world/hello_world.py", "Hello from Foundry"), + ("02-multi-protocol/multi_protocol.py", "Hello over A2A"), +] + + +@pytest.fixture +def load_sample(monkeypatch, tmp_path): + monkeypatch.setenv("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED", "false") + monkeypatch.delenv("A2A_AGENT_URL", raising=False) + monkeypatch.setenv("PORT", "8088") + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path)) + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + + def load(relative_path): + path = _SAMPLES / relative_path + spec = importlib.util.spec_from_file_location(f"a2a_sample_{path.stem}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + root = logging.getLogger() + level, handlers = root.level, list(root.handlers) + try: + with patch("azure.ai.agentserver.core._tracing._configure_tracing"): + spec.loader.exec_module(module) + finally: + root.setLevel(level) + for handler in list(root.handlers): + if handler not in handlers: + root.removeHandler(handler) + handler.close() + return module + + return load + + +async def _native_client(http_client): + http_client.headers["A2A-Version"] = "1.0" + card = await A2ACardResolver(http_client, "http://testserver").get_agent_card( + relative_card_path="a2a/.well-known/agent-card.json", + ) + return await create_client(card, ClientConfig(streaming=False, httpx_client=http_client)) + + +def _text(task): + return "".join(get_artifact_text(artifact, delimiter="") for artifact in task.artifacts) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("relative_path,expected", _EXAMPLES) +async def test_sample_task_lookup_and_context_reuse(load_sample, client_factory, relative_path, expected): + module = load_sample(relative_path) + async with client_factory(module.host) as http_client: + assert (await http_client.get("/readiness")).status_code == 200 + client = await _native_client(http_client) + try: + responses = [ + response + async for response in client.send_message( + SendMessageRequest(message=new_text_message("hello", role=Role.ROLE_USER)) + ) + ] + assert len(responses) == 1 and responses[0].HasField("task") + task = responses[0].task + assert task.id and task.context_id + assert task.status.state == TaskState.TASK_STATE_COMPLETED + assert _text(task) == expected + + stored = await client.get_task(GetTaskRequest(id=task.id)) + assert stored.id == task.id + assert stored.context_id == task.context_id + assert stored.status.state == TaskState.TASK_STATE_COMPLETED + assert _text(stored) == expected + + followup = [ + response + async for response in client.send_message( + SendMessageRequest( + message=new_text_message("hello again", role=Role.ROLE_USER, context_id=task.context_id) + ) + ) + ] + assert followup[0].task.context_id == task.context_id + assert followup[0].task.id != task.id + assert _text(followup[0].task) == expected + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_sample_shutdown_drains_background_work(load_sample, client_factory): + existing_tasks = set(asyncio.all_tasks()) + module = load_sample("01-hello-world/hello_world.py") + try: + async with client_factory(module.host) as http_client: + client = await _native_client(http_client) + try: + responses = [ + response + async for response in client.send_message( + SendMessageRequest(message=new_text_message("hello", role=Role.ROLE_USER)) + ) + ] + assert responses[0].task.status.state == TaskState.TASK_STATE_COMPLETED + finally: + await client.close() + finally: + await module.host._dispatch_shutdown() + await asyncio.sleep(0) + remaining = set(asyncio.all_tasks()) - existing_tasks + assert not [task for task in remaining if not task.done()] + + +@pytest.mark.asyncio +async def test_multi_protocol_sample_serves_invocations(load_sample, client_factory): + module = load_sample("02-multi-protocol/multi_protocol.py") + async with client_factory(module.host) as client: + response = await client.post("/invocations", json={"message": "hello"}) + assert response.status_code == 200 + assert response.json() == {"message": "Hello over Invocations"} + + +@pytest.mark.asyncio +async def test_new_sample_process_has_no_previous_tasks(load_sample, client_factory): + module = load_sample("01-hello-world/hello_world.py") + async with client_factory(module.host) as http_client: + client = await _native_client(http_client) + try: + responses = [ + response + async for response in client.send_message( + SendMessageRequest(message=new_text_message("hello", role=Role.ROLE_USER)) + ) + ] + task_id = responses[0].task.id + finally: + await client.close() + + restarted = load_sample("01-hello-world/hello_world.py") + async with client_factory(restarted.host) as http_client: + response = await http_client.post( + "/a2a", + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "lookup", "method": "GetTask", "params": {"id": task_id}}, + ) + assert response.status_code == 200 + assert response.json()["error"]["code"] == -32001 + + +@pytest.mark.asyncio +async def test_state_store_sample_retrieves_task_after_host_recreation(load_sample, client_factory): + module = load_sample("04-state-store/state_store.py") + async with client_factory(module.host) as http_client: + client = await _native_client(http_client) + try: + responses = [ + response + async for response in client.send_message( + SendMessageRequest(message=new_text_message("remember this", role=Role.ROLE_USER)) + ) + ] + task = responses[0].task + assert task.status.state == TaskState.TASK_STATE_COMPLETED + assert _text(task) == "Persisted: remember this" + finally: + await client.close() + await module.host._dispatch_shutdown() + + restarted = load_sample("04-state-store/state_store.py") + try: + async with client_factory(restarted.host) as http_client: + client = await _native_client(http_client) + try: + stored = await client.get_task(GetTaskRequest(id=task.id)) + assert stored.id == task.id + assert stored.context_id == task.context_id + assert stored.status.state == TaskState.TASK_STATE_COMPLETED + assert _text(stored) == "Persisted: remember this" + finally: + await client.close() + finally: + await restarted.host._dispatch_shutdown() + + +@pytest.mark.parametrize("relative_path,_expected", _EXAMPLES) +def test_sample_card_respects_port_and_url(load_sample, monkeypatch, relative_path, _expected): + monkeypatch.setenv("PORT", "8093") + assert load_sample(relative_path).card.supported_interfaces[0].url == "http://localhost:8093/a2a" + monkeypatch.setenv("A2A_AGENT_URL", "http://localhost:8094/a2a") + assert load_sample(relative_path).card.supported_interfaces[0].url == "http://localhost:8094/a2a" + + +@pytest.mark.asyncio +async def test_sample_client_sends_then_fetches_task(load_sample, client_factory, capsys): + server = load_sample("01-hello-world/hello_world.py") + example = load_sample("sample_client.py") + + def create_http_client(**kwargs): + client = client_factory(server.host) + client.headers.update(kwargs.get("headers", {})) + return client + + with patch.object(example.httpx, "AsyncClient", side_effect=create_http_client): + await example._run(example._parse_args(["--message", "hello"])) + output = capsys.readouterr().out + assert "TASK_STATE_COMPLETED" in output + assert "Artifact: Hello from Foundry" in output + task_id = next(line.removeprefix("Task: ") for line in output.splitlines() if line.startswith("Task: ")) + await example._run(example._parse_args(["--get-task", task_id])) + output = capsys.readouterr().out + assert f"Task: {task_id}" in output + assert "TASK_STATE_COMPLETED" in output + assert "Artifact: Hello from Foundry" in output diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_semantic_tracing.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_semantic_tracing.py new file mode 100644 index 000000000000..7e5d89130609 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_semantic_tracing.py @@ -0,0 +1,741 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for lean request-level A2A server spans.""" + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import cast +from unittest.mock import patch + +import pytest +from a2a.server.request_handlers import RequestHandler +from a2a.types import AgentCapabilities, AgentCard, AgentInterface +from opentelemetry import baggage as _otel_baggage +from opentelemetry import context as _otel_context +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry import trace +from opentelemetry.trace import SpanKind, StatusCode +from opentelemetry.sdk.trace import ReadableSpan +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.a2a._tracing import trace_jsonrpc_endpoint +from azure.ai.agentserver.core import AgentConfig +from azure.ai.agentserver.core._tracing import _FoundryEnrichmentSpanProcessor + +_RPC_PATH = "/a2a" +_FOUNDRY_COMPAT_RPC_PATH = "/invocations/a2a" +_SCOPE = "Azure.AI.AgentServer.A2A" +_HOST_SCOPE = "azure.ai.agentserver.a2a" + + +def _agent_card() -> AgentCard: + return AgentCard( + name="Tracing Agent", + description="Agent Card description", + version="1.2.3", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(streaming=True), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url="http://localhost:8088/a2a", + ) + ], + skills=[], + ) + + +def _host(endpoint) -> A2AAgentServerHost: + with patch( + "azure.ai.agentserver.a2a._host.create_jsonrpc_routes", + return_value=[Route(_RPC_PATH, endpoint, methods=["POST"])], + ): + return A2AAgentServerHost( + agent_card=_agent_card(), + request_handler=cast(RequestHandler, object()), + configure_observability=None, + ) + + +@pytest.fixture +def span_exporter(monkeypatch): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr( + "azure.ai.agentserver.a2a._tracing.trace.get_tracer", + provider.get_tracer, + ) + return exporter + + +def _a2a_spans(exporter: InMemorySpanExporter) -> list[ReadableSpan]: + return [ + span + for span in exporter.get_finished_spans() + if span.instrumentation_scope is not None and span.instrumentation_scope.name == _SCOPE + ] + + +def _attributes(span: ReadableSpan): + assert span.attributes is not None + return span.attributes + + +@pytest.fixture +def hosted_span_exporter(monkeypatch): + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "test") + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "hosted-echo") + monkeypatch.setenv("FOUNDRY_AGENT_VERSION", "42") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-42") + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor( + _FoundryEnrichmentSpanProcessor(agent_name="hosted-echo", agent_version="42", agent_id="instance-client-id") + ) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr("azure.ai.agentserver.a2a._tracing.trace.get_tracer", provider.get_tracer) + return exporter + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [_RPC_PATH, _FOUNDRY_COMPAT_RPC_PATH]) +@pytest.mark.parametrize("streaming", [False, True]) +async def test_hosted_invocation_preserves_deployment_and_agent_card_identity( + client_factory, hosted_span_exporter, path, streaming +): + result = {"task": _task_result()} + + async def generate(): + assert [s.name for s in hosted_span_exporter.get_finished_spans()] == ["agent-work"] + yield f"data: {json.dumps({'result': result})}\n\n".encode() + assert [s.name for s in hosted_span_exporter.get_finished_spans()] == ["agent-work"] + + async def endpoint(_request): + with trace.get_tracer("sample").start_as_current_span("agent-work"): + pass + return ( + StreamingResponse(generate(), media_type="text/event-stream") + if streaming + else JSONResponse({"result": result}) + ) + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + path, + headers={"traceparent": "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"}, + json={ + "method": "SendStreamingMessage" if streaming else "SendMessage", + "params": {"message": {"messageId": "message-42", "contextId": "context-42"}}, + }, + ) + + assert response.status_code == 200 + spans = hosted_span_exporter.get_finished_spans() + invocation = next(s for s in spans if s.instrumentation_scope.name == _HOST_SCOPE) + semantic = _a2a_spans(hosted_span_exporter)[0] + work = next(s for s in spans if s.name == "agent-work") + assert len(spans) == 3 + assert invocation.name == "invoke_agent hosted-echo" + assert invocation.parent.span_id == int("0123456789abcdef", 16) + assert semantic.parent.span_id == invocation.context.span_id + assert work.parent.span_id == semantic.context.span_id + host_attributes = _attributes(invocation) + assert host_attributes["gen_ai.agent.name"] == "hosted-echo" + assert host_attributes["gen_ai.agent.version"] == "42" + assert host_attributes["gen_ai.agent.id"] == "instance-client-id" + assert host_attributes["gen_ai.operation.name"] == "invoke_agent" + assert host_attributes["microsoft.session.id"] == "session-42" + assert host_attributes["gen_ai.conversation.id"] == "context-42" + assert host_attributes["a2a.task.id"] == "task-result" + assert _attributes(semantic)["gen_ai.agent.name"] == "Tracing Agent" + assert _attributes(semantic)["gen_ai.agent.version"] == "1.2.3" + assert "gen_ai.operation.name" not in _attributes(semantic) + assert invocation.start_time <= semantic.start_time <= semantic.end_time <= invocation.end_time + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["SendMessage", "SendStreamingMessage", "GetTask"]) +async def test_explicit_context_overrides_conflicting_baggage_on_request_and_child_spans( + client_factory, hosted_span_exporter, method +): + async def endpoint(_request): + assert _otel_baggage.get_baggage("azure.ai.agentserver.conversation_id") == "a2a-context" + with trace.get_tracer("sample").start_as_current_span("agent-work"): + pass + return JSONResponse({"result": {"task": _task_result()}}) + + token = _otel_context.attach(_otel_baggage.set_baggage("azure.ai.agentserver.conversation_id", "upstream-context")) + try: + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, + json={ + "method": method, + "params": ( + {"message": {"contextId": "a2a-context"}} + if method != "GetTask" + else {"contextId": "a2a-context", "id": "task-result"} + ), + }, + ) + assert response.status_code == 200 + spans = hosted_span_exporter.get_finished_spans() + assert len(spans) == (2 if method == "GetTask" else 3) + assert all(_attributes(span)["gen_ai.conversation.id"] == "a2a-context" for span in spans) + assert _otel_baggage.get_baggage("azure.ai.agentserver.conversation_id") == "upstream-context" + finally: + _otel_context.detach(token) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("http_status", [200, 503]) +async def test_hosted_invocation_records_unary_protocol_and_http_errors( + client_factory, hosted_span_exporter, http_status +): + async def endpoint(_request): + return JSONResponse({"error": {"code": -32602}}, status_code=http_status) + + async with client_factory(_host(endpoint)) as client: + await client.post(_RPC_PATH, json={"method": "SendMessage"}) + + spans = hosted_span_exporter.get_finished_spans() + assert len(spans) == 2 + for span in spans: + assert span.status.status_code == StatusCode.ERROR + assert _attributes(span)["error.type"] == ("-32602" if http_status == 200 else "503") + + +@pytest.mark.asyncio +async def test_task_lookup_is_not_mislabeled_as_an_agent_invocation(client_factory, hosted_span_exporter): + async def endpoint(_request): + return JSONResponse({"result": _task_result()}) + + async with client_factory(_host(endpoint)) as client: + await client.post(_RPC_PATH, json={"method": "GetTask", "params": {"id": "task-result"}}) + + assert len(hosted_span_exporter.get_finished_spans()) == 1 + assert len(_a2a_spans(hosted_span_exporter)) == 1 + + +def _application_data(): + return { + "taskId": "application-task", + "contextId": "application-context", + "status": {"state": "application-state"}, + "task": {"id": "application-nested-task", "status": {"state": "application-nested-state"}}, + } + + +def _task_result(): + return { + "id": "task-result", + "contextId": "ctx-result", + "status": { + "state": "TASK_STATE_COMPLETED", + "message": {"parts": [{"data": _application_data()}]}, + }, + "metadata": _application_data(), + "artifacts": [ + { + "artifactId": "artifact-result", + "metadata": _application_data(), + "parts": [{"data": _application_data()}], + } + ], + "history": [{"metadata": _application_data(), "parts": [{"data": _application_data()}]}], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,result", + [ + ("SendMessage", {"task": _task_result()}), + ("GetTask", _task_result()), + ("CancelTask", _task_result()), + ("ListTasks", {"tasks": [_task_result()]}), + ( + "SendMessage", + { + "message": { + "messageId": "message-result", + "taskId": "task-result", + "contextId": "ctx-result", + "metadata": _application_data(), + "parts": [{"data": _application_data()}], + } + }, + ), + ( + "SubscribeToTask", + { + "statusUpdate": { + "taskId": "task-result", + "contextId": "ctx-result", + "status": { + "state": "TASK_STATE_COMPLETED", + "message": {"parts": [{"data": _application_data()}]}, + }, + "metadata": _application_data(), + } + }, + ), + ( + "SendStreamingMessage", + { + "artifactUpdate": { + "taskId": "task-result", + "contextId": "ctx-result", + "artifact": {"parts": [{"data": _application_data()}], "metadata": _application_data()}, + "metadata": _application_data(), + } + }, + ), + ], +) +async def test_response_task_id_is_recorded_without_harvesting_other_metadata( + client_factory, span_exporter, method, result +): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse({"jsonrpc": "2.0", "id": "rpc-result", "result": result}) + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "rpc-result", "method": method, "params": {}}, + ) + + assert response.json()["result"] == result + attributes = _attributes(_a2a_spans(span_exporter)[0]) + if method == "ListTasks": + assert "a2a.task.id" not in attributes + else: + assert attributes["a2a.task.id"] == "task-result" + assert "gen_ai.conversation.id" not in attributes + assert "a2a.task.state" not in attributes + + +@pytest.mark.asyncio +async def test_stream_updates_capture_task_id_without_other_metadata(client_factory, span_exporter): + async def generate() -> AsyncIterator[bytes]: + for result in ( + { + "artifactUpdate": { + "taskId": "task-result", + "contextId": "ctx-result", + "artifact": {"parts": [{"data": _application_data()}]}, + } + }, + { + "statusUpdate": { + "taskId": "task-result", + "contextId": "ctx-result", + "status": {"state": "TASK_STATE_COMPLETED"}, + "metadata": _application_data(), + } + }, + ): + yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': 'rpc-result', 'result': result})}\n\n".encode() + + async def endpoint(_request: Request) -> StreamingResponse: + return StreamingResponse(generate(), media_type="text/event-stream") + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "rpc-result", "method": "SendStreamingMessage", "params": {}}, + ) + + assert response.status_code == 200 + attributes = _attributes(_a2a_spans(span_exporter)[0]) + assert "artifactUpdate" in response.text + assert "statusUpdate" in response.text + assert attributes["a2a.task.id"] == "task-result" + assert "gen_ai.conversation.id" not in attributes + assert "a2a.task.state" not in attributes + + +@pytest.mark.asyncio +async def test_list_tasks_omits_ambiguous_metadata(client_factory, span_exporter): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": "rpc-list", + "result": { + "tasks": [ + _task_result(), + { + "id": "another-task", + "contextId": "another-context", + "status": {"state": "TASK_STATE_WORKING"}, + }, + ] + }, + } + ) + + async with client_factory(_host(endpoint)) as client: + await client.post( + _RPC_PATH, + json={"jsonrpc": "2.0", "id": "rpc-list", "method": "ListTasks", "params": {}}, + ) + + attributes = _attributes(_a2a_spans(span_exporter)[0]) + assert "a2a.task.id" not in attributes + assert "gen_ai.conversation.id" not in attributes + assert "a2a.task.state" not in attributes + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [_RPC_PATH, _FOUNDRY_COMPAT_RPC_PATH]) +async def test_send_message_emits_semantic_server_span( + client_factory, + span_exporter, + path, +): + async def endpoint(_request: Request) -> JSONResponse: + assert _otel_baggage.get_baggage("azure.ai.agentserver.conversation_id") == "ctx-request" + return JSONResponse( + { + "jsonrpc": "2.0", + "id": "rpc-1", + "result": { + "task": { + "id": "task-1", + "contextId": "ctx-request", + "status": {"state": "TASK_STATE_COMPLETED"}, + } + }, + } + ) + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + path, + headers={ + "A2A-Version": "1.0", + "traceparent": "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }, + json={ + "jsonrpc": "2.0", + "id": "rpc-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "message-1", + "contextId": "ctx-request", + "referenceTaskIds": ["task-prior"], + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + } + }, + }, + ) + + assert response.status_code == 200 + spans = _a2a_spans(span_exporter) + assert len(spans) == 1 + span = spans[0] + attributes = _attributes(span) + assert span.name == "SendMessage" + assert span.kind == SpanKind.SERVER + assert span.parent is not None + assert span.context is not None + assert f"{span.context.trace_id:032x}" == "0123456789abcdef0123456789abcdef" + assert attributes["a2a.method.name"] == "SendMessage" + assert attributes["a2a.protocol.version"] == "1.0" + assert attributes["a2a.message.id"] == "message-1" + assert attributes["a2a.message.reference_task_ids"] == ("task-prior",) + assert attributes["a2a.task.id"] == "task-1" + assert "a2a.task.state" not in attributes + assert attributes["gen_ai.conversation.id"] == "ctx-request" + assert attributes["gen_ai.agent.name"] == "Tracing Agent" + assert attributes["gen_ai.agent.description"] == "Agent Card description" + assert attributes["gen_ai.agent.version"] == "1.2.3" + assert "gen_ai.operation.name" not in attributes + assert span.status.status_code == StatusCode.UNSET + + +@pytest.mark.asyncio +async def test_jsonrpc_error_marks_http_200_span_as_error( + client_factory, + span_exporter, +): + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": "rpc-error", + "error": {"code": -32602, "message": "Invalid params"}, + } + ) + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "rpc-error", + "method": "GetTask", + "params": {"id": "task-error"}, + }, + ) + + assert response.status_code == 200 + span = _a2a_spans(span_exporter)[0] + attributes = _attributes(span) + assert attributes["error.type"] == "-32602" + assert attributes["a2a.task.id"] == "task-error" + assert span.status.status_code == StatusCode.ERROR + + +@pytest.mark.asyncio +async def test_streaming_span_covers_stream_and_captures_created_task( + client_factory, + span_exporter, +): + async def generate() -> AsyncIterator[bytes]: + for state in ("TASK_STATE_WORKING", "TASK_STATE_COMPLETED"): + assert not _a2a_spans(span_exporter) + assert trace.get_current_span().is_recording() + payload = { + "jsonrpc": "2.0", + "id": "rpc-stream", + "result": { + "task": { + "id": "task-stream", + "contextId": "ctx-stream", + "status": {"state": state}, + } + }, + } + yield f"data: {json.dumps(payload)}\n\n".encode() + + async def endpoint(_request: Request) -> StreamingResponse: + return StreamingResponse(generate(), media_type="text/event-stream") + + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "rpc-stream", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "message-stream", + "contextId": "ctx-stream", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + } + }, + }, + ) + + assert response.status_code == 200 + span = _a2a_spans(span_exporter)[0] + attributes = _attributes(span) + assert span.name == "SendStreamingMessage" + assert attributes["a2a.task.id"] == "task-stream" + assert attributes["gen_ai.conversation.id"] == "ctx-stream" + assert "a2a.task.state" not in attributes + + +@pytest.mark.asyncio +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize("chunk_size", [1, 7, 65536]) +async def test_stream_task_id_handles_split_utf8_multiline_and_multiple_events( + client_factory, span_exporter, newline, chunk_size +): + task_id = "task-\u00e9" + payload = {"jsonrpc": "2.0", "result": {"task": {"id": task_id}}} + event = newline.join(f"data: {line}" for line in json.dumps(payload, ensure_ascii=False, indent=2).splitlines()) + body = (f": heartbeat{newline}{newline}data: invalid-json{newline}{newline}{event}{newline}{newline}").encode() + + async def generate(): + for offset in range(0, len(body), chunk_size): + yield body[offset : offset + chunk_size] + + async def endpoint(_request): + return StreamingResponse(generate(), media_type="text/event-stream") + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH, json={"method": "SendStreamingMessage", "params": {"message": {}}}) + + assert response.content == body + assert _attributes(_a2a_spans(span_exporter)[0])["a2a.task.id"] == task_id + + +@pytest.mark.asyncio +async def test_stream_skips_oversized_event_and_still_captures_next_task(client_factory, span_exporter): + body = b"data: " + b"x" * (1024 * 1024 + 20) + b'\n\ndata: {"result":{"task":{"id":"new-task"}}}\n\n' + + async def generate(): + for offset in range(0, len(body), 65536): + yield body[offset : offset + 65536] + + async def endpoint(_request): + return StreamingResponse(generate(), media_type="text/event-stream") + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH, json={"method": "SendStreamingMessage"}) + + assert response.content == body + assert _attributes(_a2a_spans(span_exporter)[0])["a2a.task.id"] == "new-task" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_application_data_is_not_mistaken_for_task_id(client_factory, span_exporter, streaming): + payload = {"result": {"message": {"messageId": "reply", "parts": [{"data": _application_data()}]}}} + + async def generate(): + yield f"data: {json.dumps(payload)}\n\n".encode() + + async def endpoint(_request): + return StreamingResponse(generate(), media_type="text/event-stream") if streaming else JSONResponse(payload) + + async with client_factory(_host(endpoint)) as client: + await client.post(_RPC_PATH, json={"method": "SendStreamingMessage" if streaming else "SendMessage"}) + + assert "a2a.task.id" not in _attributes(_a2a_spans(span_exporter)[0]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["GetTask", "CancelTask", "SubscribeToTask"]) +async def test_task_and_tenant_attributes_come_from_request(client_factory, span_exporter, method): + async def endpoint(_request): + return JSONResponse({"jsonrpc": "2.0", "id": "rpc", "result": _task_result()}) + + async with client_factory(_host(endpoint)) as client: + await client.post( + _RPC_PATH, + json={ + "method": method, + "params": {"id": "request-task", "contextId": "request-context", "tenant": "tenant"}, + }, + ) + + attributes = _attributes(_a2a_spans(span_exporter)[0]) + assert attributes["a2a.task.id"] == "request-task" + assert attributes["gen_ai.conversation.id"] == "request-context" + assert attributes["a2a.tenant"] == "tenant" + + +@pytest.mark.asyncio +async def test_http_error_marks_span_as_error(client_factory, span_exporter): + async def endpoint(_request): + return JSONResponse({"error": "unavailable"}, status_code=503) + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH, json={"method": "SendMessage"}) + + span = _a2a_spans(span_exporter)[0] + assert response.status_code == 503 + assert _attributes(span)["error.type"] == "503" + assert span.status.status_code == StatusCode.ERROR + + +@pytest.mark.asyncio +async def test_stream_with_request_task_id_bypasses_response_observation(client_factory, span_exporter): + body = b'data: {"result":{"task":{"id":"response-task"}}}\n\n' + + async def generate(): + yield body + + async def endpoint(_request): + return StreamingResponse(generate(), media_type="text/event-stream") + + with patch("azure.ai.agentserver.a2a._tracing._TracedResponse.observe_chunk") as observe: + async with client_factory(_host(endpoint)) as client: + response = await client.post( + _RPC_PATH, json={"method": "SubscribeToTask", "params": {"id": "request-task"}} + ) + observe.assert_not_called() + + assert response.content == body + assert _attributes(_a2a_spans(span_exporter)[0])["a2a.task.id"] == "request-task" + + +@pytest.mark.asyncio +async def test_sse_protocol_errors_are_not_inspected(client_factory, span_exporter): + body = b'data: {"jsonrpc":"2.0","error":{"code":-32603}}\n\n' + + async def generate(): + yield body + + async def endpoint(_request): + return StreamingResponse(generate(), media_type="text/event-stream") + + async with client_factory(_host(endpoint)) as client: + response = await client.post(_RPC_PATH, json={"method": "SendStreamingMessage"}) + + span = _a2a_spans(span_exporter)[0] + assert response.content == body + assert span.status.status_code == StatusCode.UNSET + assert "error.type" not in _attributes(span) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("during_send", [False, True]) +@pytest.mark.parametrize("exception_type", [RuntimeError, asyncio.CancelledError]) +@pytest.mark.parametrize("hosted", [False, True]) +async def test_exception_or_cancellation_ends_span_and_restores_context( + span_exporter, during_send, exception_type, hosted +): + original_span = trace.get_current_span() + original_conversation = _otel_baggage.get_baggage("azure.ai.agentserver.conversation_id") + scope = {"type": "http", "method": "POST", "path": _RPC_PATH, "headers": [], "query_string": b""} + + async def receive(): + return { + "type": "http.request", + "body": b'{"method":"SendMessage","params":{"message":{"contextId":"ctx-error"}}}', + "more_body": False, + } + + async def endpoint(_request): + if not during_send: + raise exception_type("endpoint failed") + return Response("ok") + + async def send(_message): + raise exception_type("send failed") + + config = AgentConfig.from_env() + config.is_hosted = hosted + config.agent_name = "hosted-echo" + config.agent_version = "42" + with pytest.raises(exception_type): + response = await trace_jsonrpc_endpoint(endpoint, _agent_card(), config=config)(Request(scope, receive)) + assert not _a2a_spans(span_exporter) + await response(scope, receive, send) + + span = _a2a_spans(span_exporter)[0] + if exception_type is RuntimeError: + assert span.status.status_code == StatusCode.ERROR + assert _attributes(span)["error.type"] == "RuntimeError" + assert len(span.events) == 1 + else: + assert span.status.status_code == StatusCode.UNSET + assert not span.events + spans = span_exporter.get_finished_spans() + assert len(spans) == (2 if hosted else 1) + assert all(s.status.status_code == span.status.status_code for s in spans) + assert trace.get_current_span() is original_span + assert _otel_baggage.get_baggage("azure.ai.agentserver.conversation_id") == original_conversation diff --git a/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_server_routes.py b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_server_routes.py new file mode 100644 index 000000000000..910fad0f7eff --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-a2a/tests/test_server_routes.py @@ -0,0 +1,357 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for A2AAgentServerHost routes and request context.""" + +from typing import cast +from unittest.mock import patch + +import pytest +from a2a.helpers import new_text_message +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler, RequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentInterface +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from azure.ai.agentserver.a2a import A2AAgentServerHost +from azure.ai.agentserver.a2a._version import VERSION +from azure.ai.agentserver.core import AgentServerHost, get_request_context +from azure.ai.agentserver.invocations import InvocationAgentServerHost + +_RPC_PATH = "/a2a" +_FOUNDRY_COMPAT_RPC_PATH = "/invocations/a2a" +_CARD_PATH = "/a2a/.well-known/agent-card.json" +_FOUNDRY_COMPAT_CARD_PATH = "/invocations/a2a/.well-known/agent-card.json" +_CARD_PATHS = [_CARD_PATH, _FOUNDRY_COMPAT_CARD_PATH] + + +class _NoOpExecutor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise NotImplementedError + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise NotImplementedError + + +class _StreamingExecutor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + del context + await event_queue.enqueue_event(new_text_message("streamed")) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + del context, event_queue + raise NotImplementedError + + +def _agent_card() -> AgentCard: + return AgentCard( + name="hello", + description="test agent", + version="1.0.0", + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url="http://localhost:8088/a2a", + ) + ], + skills=[], + ) + + +def _request_handler(card: AgentCard) -> RequestHandler: + return DefaultRequestHandler( + agent_executor=_NoOpExecutor(), + task_store=InMemoryTaskStore(), + agent_card=card, + ) + + +def _host(**kwargs) -> A2AAgentServerHost: + card = _agent_card() + return A2AAgentServerHost( + agent_card=card, + request_handler=_request_handler(card), + configure_observability=None, + **kwargs, + ) + + +def test_registers_canonical_and_foundry_compatibility_routes(): + host = _host() + base_host = AgentServerHost(configure_observability=None) + + paths = [getattr(route, "path", None) for route in host.routes] + base_paths = [getattr(route, "path", None) for route in base_host.routes] + + a2a_paths = [ + _RPC_PATH, + _FOUNDRY_COMPAT_RPC_PATH, + _CARD_PATH, + _FOUNDRY_COMPAT_CARD_PATH, + ] + assert paths == a2a_paths + base_paths + assert "/readiness" in base_paths + assert "/.well-known/agent-card.json" not in paths + assert "/a2a/extendedAgentCard" not in paths + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _CARD_PATHS) +async def test_serves_agent_card_route(client_factory, path): + host = _host() + + async with client_factory(host) as client: + response = await client.get(path, headers={"x-request-id": "card-request"}) + + assert response.status_code == 200 + assert response.json()["name"] == "hello" + assert response.json()["supportedInterfaces"][0]["url"] == "http://localhost:8088/a2a" + assert response.headers["x-request-id"] == "card-request" + assert "azure-ai-agentserver-a2a/" in response.headers["x-platform-server"] + assert "location" not in response.headers + + +def test_agent_card_alias_reuses_native_endpoint(): + routes = {route.path: route for route in _host().routes if isinstance(route, Route)} + assert routes[_CARD_PATH].endpoint is routes[_FOUNDRY_COMPAT_CARD_PATH].endpoint + assert not routes[_FOUNDRY_COMPAT_CARD_PATH].include_in_schema + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _CARD_PATHS) +async def test_agent_card_modifier_has_platform_context(client_factory, path): + seen = [] + + async def modify_card(card: AgentCard) -> AgentCard: + context = get_request_context() + seen.append((context.call_id, context.user_id)) + modified = AgentCard() + modified.CopyFrom(card) + modified.description = "Modified card" + return modified + + async with client_factory(_host(card_modifier=modify_card)) as client: + response = await client.get( + path, + headers={"x-agent-foundry-call-id": "card-call", "x-agent-user-id": "card-user"}, + ) + + assert response.status_code == 200 + assert response.json()["description"] == "Modified card" + assert seen == [("card-call", "card-user")] + assert get_request_context().call_id is None + assert get_request_context().user_id is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", _CARD_PATHS) +async def test_agent_card_routes_support_head(client_factory, path): + async with client_factory(_host()) as client: + response = await client.head(path, headers={"x-request-id": "card-head"}) + assert response.status_code == 200 + assert response.content == b"" + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-request-id"] == "card-head" + assert "azure-ai-agentserver-a2a/" in response.headers["x-platform-server"] + + +@pytest.mark.asyncio +async def test_jsonrpc_route_does_not_redirect_trailing_slash(client_factory): + host = _host() + + async with client_factory(host) as client: + response = await client.post(_RPC_PATH, json={}) + + assert response.status_code == 200 + assert response.headers.get("location") is None + + +@pytest.mark.asyncio +async def test_send_streaming_message_returns_sse(client_factory): + card = _agent_card() + card.capabilities.streaming = True + host = A2AAgentServerHost( + agent_card=card, + request_handler=DefaultRequestHandler( + agent_executor=_StreamingExecutor(), + task_store=InMemoryTaskStore(), + agent_card=card, + ), + configure_observability=None, + ) + + async with client_factory(host) as client: + response = await client.post( + _RPC_PATH, + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "m1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + } + }, + }, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "data:" in response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [_RPC_PATH, _FOUNDRY_COMPAT_RPC_PATH]) +async def test_binds_and_resets_platform_request_context(client_factory, path): + async def inspect_context(_request: Request) -> JSONResponse: + context = get_request_context() + return JSONResponse( + { + "call_id": context.call_id, + "user_id": context.user_id, + "session_id": context.session_id, + "outbound_headers": context.platform_headers(), + } + ) + + with patch( + "azure.ai.agentserver.a2a._host.create_jsonrpc_routes", + return_value=[Route(_RPC_PATH, inspect_context, methods=["POST"])], + ): + host = _host() + + async with client_factory(host) as client: + response = await client.post( + path, + json={}, + headers={ + "x-agent-foundry-call-id": "call-123", + "x-agent-user-id": "user-456", + "x-agent-session-id": "session-789", + }, + ) + + assert response.json() == { + "call_id": "call-123", + "user_id": "user-456", + "session_id": None, + "outbound_headers": {"x-agent-foundry-call-id": "call-123"}, + } + assert get_request_context().call_id is None + assert get_request_context().user_id is None + assert get_request_context().session_id is None + + +@pytest.mark.asyncio +async def test_platform_context_is_limited_to_a2a_routes(client_factory): + async def inspect_context(_request: Request) -> JSONResponse: + context = get_request_context() + return JSONResponse({"call_id": context.call_id, "user_id": context.user_id}) + + host = _host(routes=[Route("/inspect", inspect_context, methods=["GET"])]) + + async with client_factory(host) as client: + response = await client.get( + "/inspect", + headers={ + "x-agent-foundry-call-id": "must-not-bind", + "x-agent-user-id": "must-not-bind", + }, + ) + + assert response.json() == {"call_id": None, "user_id": None} + + +def test_disables_v0_3_compatibility(): + card = _agent_card() + handler = _request_handler(card) + + with patch( + "azure.ai.agentserver.a2a._host.create_jsonrpc_routes", + return_value=[Route(_RPC_PATH, lambda _request: JSONResponse({}), methods=["POST"])], + ) as create_routes: + A2AAgentServerHost( + agent_card=card, + request_handler=handler, + configure_observability=None, + ) + + assert create_routes.call_args.kwargs["enable_v0_3_compat"] is False + + +def test_exposes_native_card_handler_and_server_version(): + card = _agent_card() + handler = _request_handler(card) + host = A2AAgentServerHost( + agent_card=card, + request_handler=handler, + configure_observability=None, + ) + + assert host.agent_card is card + assert host.request_handler is handler + assert "azure-ai-agentserver-a2a" in host._build_server_version() + assert VERSION in host._build_server_version() + + +@pytest.mark.asyncio +async def test_composes_with_invocations_on_one_server(client_factory): + class MultiProtocolHost(A2AAgentServerHost, InvocationAgentServerHost): + pass + + card = _agent_card() + host = MultiProtocolHost( + agent_card=card, + request_handler=_request_handler(card), + configure_observability=None, + ) + + @host.invoke_handler + async def handle_invocation(_request: Request) -> JSONResponse: + return JSONResponse({"protocol": "invocations"}) + + async with client_factory(host) as client: + a2a_response = await client.get(_CARD_PATH) + invocation_response = await client.post("/invocations", json={}) + + assert a2a_response.status_code == 200 + assert invocation_response.status_code == 200 + assert invocation_response.json() == {"protocol": "invocations"} + + +def test_native_route_factories_receive_canonical_paths(): + card = _agent_card() + handler = _request_handler(card) + + with ( + patch( + "azure.ai.agentserver.a2a._host.create_jsonrpc_routes", + return_value=[Route(_RPC_PATH, lambda _request: JSONResponse({}), methods=["POST"])], + ) as create_rpc_routes, + patch( + "azure.ai.agentserver.a2a._host.create_agent_card_routes", + return_value=[Route(_CARD_PATH, lambda _request: JSONResponse({}), methods=["GET"])], + ) as create_card_routes, + ): + A2AAgentServerHost( + agent_card=card, + request_handler=handler, + configure_observability=None, + ) + + assert create_rpc_routes.call_args.kwargs["rpc_url"] == _RPC_PATH + assert create_card_routes.call_args.kwargs["card_url"] == _CARD_PATH + assert create_card_routes.call_count == 1 diff --git a/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md index 28aa20f09ae2..01ab407c3a86 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md @@ -1,15 +1,34 @@ # Release History -## 1.0.0b4 (Unreleased) +## 1.0.0b5 (Unreleased) ### Features Added -### Breaking Changes +- Capture invocation-local original-subject assertions from platform headers, + independently of Activity payload identity and user-state partition keys. + +### Bugs Fixed + +- Return a classified error for malformed platform attribution before binding + request context or invoking the Activity handler. + +### Other Changes + +- Require Core `2.3.0b2` or later. Publish that Core version before this package. + +## 1.0.0b4 (2026-10-12) ### Bugs Fixed +- Fixed compatibility with Microsoft 365 Agents SDK 1.8.0 by removing the + unsupported `ClaimsIdentity.is_authenticated` constructor argument. + Anonymous and bearer outbound authentication behavior is unchanged. + ### Other Changes +- Raised the minimum `microsoft-agents-hosting-core` version to `1.4.0` to use + the supported claims constructor and `allow_anonymous` API. + ## 1.0.0b3 (2026-08-21) ### Features Added diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py index a4843750be13..ee0083e19a52 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py @@ -37,6 +37,7 @@ from azure.ai.agentserver.core import ( AgentServerHost, FoundryAgentRequestContext, + FoundryOriginalSubject, build_server_version, create_error_response, detach_context, @@ -808,6 +809,16 @@ async def _create_activity_endpoint(self, request: Request) -> Response: inbound_call_id = request.headers.get(FOUNDRY_CALL_ID, "") session_id = _sanitize_id(self._resolve_session_id(request), str(uuid.uuid4())) + try: + original_subject = FoundryOriginalSubject.from_headers(request.headers) + except ValueError: + response = self._build_bad_request( + "Invalid original-subject platform header.", session_id, "invalid_original_subject" + ) + response.headers[ERROR_SOURCE] = ErrorSource.PLATFORM + response.headers[ERROR_DETAIL] = "invalid_original_subject" + return response + # Bind platform context so handler/tool code can forward the per-request # call ID and user ID, and so log records carry the correlation fields. ctx_token = set_request_context( @@ -815,6 +826,7 @@ async def _create_activity_endpoint(self, request: Request) -> Response: call_id=inbound_call_id or None, user_id=inbound_user_id or None, session_id=session_id, + original_subject=original_subject, ) ) baggage_token: Optional[Token[Context]] = None diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py index 6427b30dd92e..28ccaa917942 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py @@ -226,11 +226,11 @@ def _build_outbound_claims( "as the Microsoft 365 Agents Playground 'emulator' channel.", OutboundAuth.CLAIM_APP_ID, ) - return claims_cls({}, is_authenticated=False, authentication_type=OutboundAuth.AUTH_TYPE_ANONYMOUS) + return claims_cls({}, authentication_type=OutboundAuth.AUTH_TYPE_ANONYMOUS) # Simple model: present authenticated claims whose appid matches the # service-connection client id (the agent instance identity). This makes the # adapter use the real MSAL ``UserManagedIdentity`` connection for the # outbound reply instead of an anonymous/empty token. claim_dict = {OutboundAuth.CLAIM_APP_ID: bot_app_id, OutboundAuth.CLAIM_AUDIENCE: bot_app_id} if bot_app_id else {} - return claims_cls(claim_dict, is_authenticated=True, authentication_type=OutboundAuth.AUTH_TYPE_BEARER) + return claims_cls(claim_dict, authentication_type=OutboundAuth.AUTH_TYPE_BEARER) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py index 95e11ac1bdc7..eecd2a8e450f 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "1.0.0b4" +VERSION = "1.0.0b5" diff --git a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt index be9ecf0c8970..73d733752bb5 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt +++ b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt @@ -4,7 +4,7 @@ pytest pytest-asyncio opentelemetry-sdk>=1.43.0 -microsoft-agents-hosting-core>=1.1.0 +microsoft-agents-hosting-core>=1.4.0 microsoft-agents-authentication-msal>=1.1.0 microsoft-agents-activity>=1.1.0 ../../monitor/azure-monitor-query diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml index b6650b0ff20d..1c8d9017c990 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml @@ -21,10 +21,10 @@ classifiers = [ keywords = ["azure", "azure sdk", "agent", "agentserver", "activity"] dependencies = [ - "azure-ai-agentserver-core>=2.0.0b8", + "azure-ai-agentserver-core>=2.3.0b2,<3.0.0", "opentelemetry-api>=1.43.0", "aiohttp>=3.10.0,<4.0.0a0", - "microsoft-agents-hosting-core>=1.1.0", + "microsoft-agents-hosting-core>=1.4.0", "microsoft-agents-authentication-msal>=1.1.0", "microsoft-agents-activity>=1.1.0", ] @@ -36,7 +36,7 @@ dev = [ "opentelemetry-sdk>=1.43.0", "pytest-asyncio", "pytest", - "microsoft-agents-hosting-core>=1.1.0", + "microsoft-agents-hosting-core>=1.4.0", "microsoft-agents-authentication-msal>=1.1.0", "microsoft-agents-activity>=1.1.0", "azure-identity>=1.25.3", diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_bridge_turn.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_bridge_turn.py index 43c33c9ccc13..3c3364f5f220 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_bridge_turn.py +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_bridge_turn.py @@ -97,7 +97,7 @@ def test_claims_digital_worker_is_anonymous(): """Digital-worker model uses anonymous claims (FMI patch supplies the token).""" claims = bridge._build_outbound_claims(ClaimsIdentity, digital_worker=True, is_hosted=True, bot_app_id="ignored") - assert claims.is_authenticated is False + assert claims.allow_anonymous is True assert claims.authentication_type == OutboundAuth.AUTH_TYPE_ANONYMOUS @@ -108,7 +108,7 @@ def test_claims_simple_local_no_creds_is_anonymous(caplog): with caplog.at_level(logging.WARNING, logger="azure.ai.agentserver.activity.bridge"): claims = bridge._build_outbound_claims(ClaimsIdentity, digital_worker=False, is_hosted=False, bot_app_id="") - assert claims.is_authenticated is False + assert claims.allow_anonymous is True assert claims.authentication_type == OutboundAuth.AUTH_TYPE_ANONYMOUS assert any("LOCAL DEV" in r.message for r in caplog.records) @@ -120,7 +120,7 @@ def test_claims_simple_hosted_is_authenticated_bearer(): ClaimsIdentity, digital_worker=False, is_hosted=True, bot_app_id="client-xyz" ) - assert claims.is_authenticated is True + assert claims.allow_anonymous is False assert claims.authentication_type == OutboundAuth.AUTH_TYPE_BEARER assert claims.get_claim_value(OutboundAuth.CLAIM_APP_ID) == "client-xyz" assert claims.get_claim_value(OutboundAuth.CLAIM_AUDIENCE) == "client-xyz" @@ -133,7 +133,7 @@ def test_claims_simple_with_credential_local_is_authenticated(): ClaimsIdentity, digital_worker=False, is_hosted=False, bot_app_id="client-xyz" ) - assert claims.is_authenticated is True + assert claims.allow_anonymous is False assert claims.authentication_type == OutboundAuth.AUTH_TYPE_BEARER @@ -142,8 +142,7 @@ def test_claims_authenticated_with_empty_bot_app_id_has_empty_claims(): but with no appid/audience entries.""" claims = bridge._build_outbound_claims(ClaimsIdentity, digital_worker=False, is_hosted=True, bot_app_id="") - # ``is_authenticated`` is deprecated upstream and now derives from the claim - # dict being non-empty, so assert on the authentication type instead. + assert claims.allow_anonymous is False assert claims.authentication_type == OutboundAuth.AUTH_TYPE_BEARER assert claims.get_claim_value(OutboundAuth.CLAIM_APP_ID) is None assert claims.get_claim_value(OutboundAuth.CLAIM_AUDIENCE) is None @@ -179,7 +178,7 @@ def test_process_delegates_to_process_request(): # The adapted request exposes the synthesized outbound claims (authenticated # Bearer because hosted + bot app id) and the parsed activity dict. claims = adapted_request.get_claims_identity() - assert claims.is_authenticated is True + assert claims.allow_anonymous is False assert claims.authentication_type == OutboundAuth.AUTH_TYPE_BEARER assert _run(adapted_request.json()) == {"type": "message", "conversation": {"id": "c1"}} @@ -252,7 +251,7 @@ def test_request_adapter_exposes_request_surface(): """The adapter maps method / headers / json / path params from the request, and reads the claims identity attached to ``request.state`` (as auth middleware / the bridge sets it).""" - claims = ClaimsIdentity({}, is_authenticated=False) + claims = ClaimsIdentity({}, authentication_type=OutboundAuth.AUTH_TYPE_ANONYMOUS) request = _make_request( {"type": "message"}, method="POST", @@ -350,7 +349,7 @@ def test_build_bridge_handler_returns_working_handler(): adapted_request, passed_agent = adapter.calls[0] assert passed_agent is agent_app # Digital-worker model -> anonymous outbound claims bound into the request. - assert adapted_request.get_claims_identity().is_authenticated is False + assert adapted_request.get_claims_identity().allow_anonymous is True # --------------------------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_session_headers.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_session_headers.py index 4b658402cd27..f9ca272ee391 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_session_headers.py +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_session_headers.py @@ -3,10 +3,93 @@ # --------------------------------------------------------- """Tests for session resolution and required response headers.""" +from importlib.metadata import requires, version +from urllib.parse import unquote + import pytest +from packaging.requirements import Requirement +from packaging.version import Version from starlette.responses import JSONResponse from azure.ai.agentserver.activity import ActivityAgentServerHost +from azure.ai.agentserver.core import FoundryAgentRequestContext, get_request_context + + +def test_original_subject_core_dependency_floor(): + requirements = (Requirement(value) for value in requires("azure-ai-agentserver-activity") or []) + core = next(value for value in requirements if value.name == "azure-ai-agentserver-core") + package_version = Version(version("azure-ai-agentserver-activity")) + if package_version.pre and package_version.pre[0] == "a": + assert core.specifier.contains(Version(version("azure-ai-agentserver-core")), prereleases=True) + else: + assert "2.3.0b2" in core.specifier + assert "2.3.0b1" not in core.specifier + assert "2.0.0b8" not in core.specifier + assert callable(FoundryAgentRequestContext.model_attribution_headers) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", ["malformed", 'oid="one", oid="two"', 'unknown="value"']) +async def test_malformed_original_subject_is_classified_before_handler(value, asgi_client): + handled = [] + + async def handle(_request): + handled.append(True) + return JSONResponse(get_request_context().model_attribution_headers()) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + payload = {"type": "message", "conversation": {"id": "conversation"}} + async with asgi_client(app) as client: + response = await client.post( + "/activity/messages", json=payload, + headers={"x-ms-original-subject": value, "Authorization": "Bearer test-token"}, + ) + assert response.status_code == 400 + assert response.headers["x-platform-error-source"] == "platform" + assert response.headers["x-platform-error-detail"] == "invalid_original_subject" + assert not handled + response = await client.post( + "/activity/messages", json=payload, headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + assert response.json() == {} + assert get_request_context().original_subject is None + + +@pytest.mark.asyncio +async def test_original_subject_comes_from_headers_not_activity_payload(asgi_client): + async def handle(_request): + return JSONResponse(get_request_context().model_attribution_headers()) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + subject = { + "x-ms-original-subject": 'oid="platform-original-oid", tid="platform-original-tid", idtype="user"', + } + payload = { + "type": "message", + "from": {"aadObjectId": "payload-spoof"}, + "channelData": {"tenant": {"id": "payload-spoof"}}, + "conversation": {"id": "conversation"}, + } + async with asgi_client(app) as client: + response = await client.post( + "/activity/messages", + json=payload, + headers={**subject, "Authorization": "Bearer bot-token", "x-agent-user-id": "state-key"}, + ) + assert response.status_code == 200 + forwarded = response.json() + assert not any(name.lower().startswith("x-ms-original-subject") for name in forwarded) + assert unquote(forwarded["x-ms-foundry-baggage"].split("=", 1)[1]) == subject["x-ms-original-subject"] + assert len(forwarded) == 1 + response = await client.post( + "/activity/messages", + json=payload, + headers={"Authorization": "Bearer bot-token"}, + ) + assert response.status_code == 200 + assert response.json() == {} + assert get_request_context().original_subject is None @pytest.mark.asyncio diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 177ca386dd6f..61a4a4c81f10 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -1,5 +1,227 @@ # Release History +## 2.4.0b1 (Unreleased) + +### Features Added + +- Apply protocol-specific runtime requirements to deferred snapshot activation, + preserving independent Invocations/Responses opt-in and owning-host recovery + callback bindings. +- Allow decorated task submission inside `after_restore_handler`, preparing the + session-bound TaskManager before the callback and starting automatic recovery + only after application dependencies are ready. +- Separate process-side task registration from session-bound task activation. + `FOUNDRY_MEMORY_SNAPSHOT_ENABLED=true` defers enabled tasks during capture until + after-restore; absent or false keeps ordinary cold startup. Resilience opt-out + remains disabled for both initialization paths. Preserve + best-effort recovery, checkpoint initialization phases by restore ID, wait for + owned workers to stop before incompatible configuration rollback, and stop + managers created after startup at shutdown. + +### Bugs Fixed + +- Track task identity throughout inline start/run claims and positional active- + run lookup, preventing recovery from dispatching work before claim registration. +- Coordinate same-task producers across lease claims and worker registration, + preventing duplicate execution when inline lookup follows a committed recovery + claim whose provider response is still pending. Nested calls from the same + producer remain reentrant, and cancelled waiters release their admission. +- Unpublish incompatible prepared task runtimes before failed or cancelled + restore configuration rollback, even when no workers started, preventing + subsequent submissions from using the failed provider or session binding. +- Interrupt blocked recovery reads when shutdown begins, so admission can drain + before scanner cancellation while accepted creates and claims settle safely. + Host shutdown signals an activating manager before waiting for the serialized + manager lock, so a blocked after-restore recovery read cannot stall cleanup. +- Wait for cancelled task workers during host teardown while a restore rollback is + still pending, so their async cleanup cannot observe session or provider + configuration reverted underneath it. Preserve unfinished records and inputs + before shutdown wakes workers, so a failed restore can recover them on retry. + Ordinary shutdown otherwise stays non-joining. +- Keep the lease instance consistent with live task workers and accepted + submissions across compatible in-place restores with a new restore ID. + Idle runtimes without owned task work still rotate their lease instance. +- Validate live provider overrides before publishing restored configuration or + rotating lease identity, and defer accepted task callers cancelled during + initial watchdog setup before their handlers start. +- Reject live session GUID changes before rotating lease identity, prevent + steering dispatch across shutdown bookkeeping, and defer queued/promoted + steering callers without removing their recoverable inputs. +- Include agent identity and the resolved default task directory in runtime + compatibility, preventing rollback from retaining incompatible lease owners + or providers rooted in an overridden home directory. +- Close task admission during manager shutdown and settle accepted submissions + before worker snapshots, preventing cancellation cleanup from launching + untracked child tasks. +- Quiesce failed-restore workers before provider configuration rollback even + when the session ID/GUID is unchanged, preserving their original task records + and allowing retries with omitted provider overrides. +- Rebuild inactive task providers when hydrated configuration changes, and + recreate disposed runtimes for cached restore requests without replaying + completed application callbacks. +- Preserve sanitized lifecycle error responses when required restore cleanup + fails, retaining pending rollback state for retry. +- Preserve environment and configuration rollback snapshots across failed + restore cleanup retries so sparse overrides retain the captured baseline. +- Retry interrupted restore rollback cleanup before activating a replacement + TaskManager, retaining queued-input reconciliation handles across transient + storage failures. +- Reset task startup admission when reusing a host across sequential lifespans. +- Reconcile interrupted steering transitions before restore rollback completes, + retaining queued work as recoverable without dispatching a new turn. +- Preserve unfinished task records and saved inputs when failed restore + activation stops workers before rolling back session configuration. +- Keep the lease instance stable on same-restore retries, retain worker and + lease handles until workers finish, and defer steering execution during + rollback while keeping queued input eligible for recovery. +- Close internally created hosted task providers and their credentials before + discarding a stopped TaskManager after failed restores or inactive provider + replacement, keeping the manager for a cleanup retry if the close fails. + Ordinary host shutdown closes after cancelled workers finish. Injected + providers remain caller-owned. +- Let handlers start during the ordinary shutdown grace period so they can defer + with `ctx.exit_for_recovery()`, instead of cancelling retried or accepted work + before dispatch and deleting one-shot records. Rollback cleanup still retains + that work without dispatching it. +- Release the lease of a steering input staged during ordinary shutdown, making + it recoverable without waiting for lease expiry. + +## 2.3.1b1 (2026-11-02) + +### Bugs Fixed + +- Preserve explicit Agent Card name and version attributes on spans emitted + by the `Azure.AI.AgentServer.A2A` instrumentation scope. Other spans retain + the existing Foundry deployment identity enrichment. + +## 2.3.0 (2026-10-29) + +### Features Added + +- Add one percent-encoded `foundry.original_subject` member containing the full + quoted dictionary to custom Foundry baggage for baggage-only model attribution. + The experimental `model_attribution_headers` helper accepts optional existing + per-call headers, preserving unrelated dimensions without mutating defaults + and replacing or clearing competing subject assertions. + +### Breaking Changes + +- Removed the internal `EXPERIMENTAL_LINK_MESSAGE` constant. When using + `azure-ai-agentserver-responses`, upgrade it to `2.4.0` or later alongside + Core; older Responses compatibility shims require the removed constant. + +### Bugs Fixed + +- Removed the broken documentation link from experimental API warnings and docstrings. +- Stop emitting standalone subject headers on outbound model calls while + preserving platform-to-container ingress parsing. +- Remove stale subject baggage even when a malformed member has properties before + its value, while preserving unrelated baggage order, encoding, and duplicates. +- Reject subject tuples over 256 characters and merged baggage over 1024 + characters rather than forwarding oversized model-attribution baggage. +- Place the subject before unrelated baggage so bounded transport normalization + cannot displace the attribution member. + +### Other Changes + +- Stable release of the 2.3.0 preview series, including the protocol-scoped task + runtime and recovery protections described in the preview entries below. + +## 2.3.0b2 (2026-10-26) + +### Features Added + +- Added immutable `FoundryOriginalSubject` assertions and invocation-local + `FoundryAgentRequestContext.original_subject` with an explicit, model-only + `model_attribution_headers()` helper for the proposed `x-ms-original-subject` + dictionary contract. The assertions do not establish identity proof or change + model authentication; model integration remains an explicit per-call opt-in. + Both the assertions class and header helper are marked experimental. +- Added protocol-scoped task-runtime startup requests so protocol hosts can + obtain scoped `TaskManager` access without mutating the process-wide + resilient-task switch. This prevents one protocol's enablement from + activating another protocol in a mixed host. + +### Bugs Fixed + +- Preserve host-owned recovery callbacks and task options when loading + process-wide task descriptors at startup. +- Exclude protocol-disabled task functions before startup, periodic or inline + recovery can reclaim their leases or dispatch their handlers. +- Require explicit owning-host registration for protocol-internal recovery + callbacks instead of importing them into unrelated managers. +- Treat a persisted task function identity as authoritative before recovery + cleanup or reclaim. Legacy callback fallback applies only when that + identity is absent. + +## 2.3.0b1 (2026-10-15) + +### Features Added + +- Added `AgentServerHost.readiness_handler` for replacing the default + `GET /readiness` response with application-specific readiness logic. When no + handler is registered, the existing healthy response remains unchanged. +- Added the private `POST /_agent/before-snapshot` and + `POST /_agent/after-restore` lifecycle endpoints, with equivalent + `POST /_before_snapshot` and `POST /_after_restore` aliases, for safely + capturing and restoring hosted agent processes. Applications can register + `before_snapshot_handler` and `after_restore_handler` callbacks; lifecycle + calls are serialized and idempotent, and restored session environment + overrides are applied before the after-restore callback runs. Protocol + subclasses can extend the lifecycle through protected hooks without + bypassing the host's required hydration and rollback behavior. Later + materializations reset omitted overrides to their captured values and expose + the restored session through the ambient request context during callbacks. + Environment-backed `AgentConfig` values are refreshed in place before + callbacks run, and `AgentSessionContext` is marked experimental. + +### Bugs Fixed + +- Preserve an observed final terminal marker before recovery-time TTL + compaction, keeping closed replay logs closed across subsequent restarts. +- Retain replay backing ownership through deletion: keep POSIX inode locks + until unlink and Windows sidecar locks until log removal. Acquire Windows + ownership before opening the log, reject stale or replaced locked descriptors, + and retry cleanup without unlinking a successor's backing file. +- Release owned replay-file and writer-lock resources on every rehydration + failure, including callback errors and cancellation. Surface cleanup errors + with their original failure context and retain exact registry cleanup owners + when resource release must be retried. +- Fail closed after compaction replaces the log but cannot establish a usable + writer. Retire stale handles, reject further writes, wake subscribers with an + explicit error, and reacquire ownership before deleting a failed replacement. + Keep the original writer usable when preparation fails before replacement. +- Open existing replay logs in non-creating mode during cold lookup and + compaction reopen, preventing concurrent removal from recreating an empty + log. Preserve explicit creation and propagate backing-lock acquisition errors. +- Delete cold file-backed replay logs without deserializing their events, so + malformed logs remain removable after restart. Preserve locking and retryable + cleanup when file access, lock acquisition, or removal fails. +- Apply explicit task-list limits to the total returned records for both hosted + and local providers, clamped to 100. Keep omitted or `None` limits as complete + enumeration so recovery and response-ownership scans include every page. +- Reclaim per-ID stream lifecycle locks after their last holder or waiter exits, + including cancellation, without splitting concurrent lookup, creation, or deletion. +- Enumerate every matching local durable task when no listing limit is + requested, matching hosted task enumeration. Reject missing or repeated + hosted continuation cursors instead of returning an incomplete task list. +- Restore existing file-backed replay logs during stream lookup and deletion + after restart without creating absent logs. Serialize lookup, creation, and + deletion for the same stream ID within the registry. +- Propagate replay-file deletion failures without installing a successful + deletion tombstone, allowing cleanup to be retried. +- Make Windows replay lock-file removal failures retryable without closing + an already-released file descriptor or reporting successful deletion. + +### Other Changes + +- Removed the experimental marker (docstring note and one-time runtime warning) + from the resilient task public APIs (`TaskContext`, `TaskRun`, `@task`, + `@multi_turn_task`, `set_resilient_tasks_enabled`, task exceptions, and + retry policy). +- Changed the default SSE keep-alive interval to 15 seconds. Set + `SSE_KEEPALIVE_INTERVAL=0` to disable keep-alive comments. + ## 2.2.0 (2026-09-23) ### Other Changes diff --git a/sdk/agentserver/azure-ai-agentserver-core/README.md b/sdk/agentserver/azure-ai-agentserver-core/README.md index 35bcfd7d3063..688029250a76 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/README.md +++ b/sdk/agentserver/azure-ai-agentserver-core/README.md @@ -2,6 +2,34 @@ The `azure-ai-agentserver-core` package provides the foundation host framework for building Azure AI Hosted Agent containers. It handles the protocol-agnostic infrastructure — health probes, graceful shutdown, OpenTelemetry tracing, and ASGI serving — so that protocol packages can focus on their endpoint logic. +## Memory snapshot task initialization + +The draft integration separates task definitions from session-bound TaskManager +initialization. Resilient tasks must still be explicitly enabled. + +`FOUNDRY_MEMORY_SNAPSHOT_ENABLED=true` defers enabled tasks in the temporary +capture sandbox until the platform after-restore hook. After session hydration, +the manager is prepared before the application callback, and automatic recovery +follows that callback. An absent value or `false` activates requested tasks during +ordinary cold/local startup. For standalone Core and Invocations, +`set_resilient_tasks_enabled(False)` keeps task execution disabled on both paths. +Responses independently requests its runtime through `resilient_background` or +`steerable_conversations`; the Invocations switch does not disable those options. +Present values must be literal `true` or `false` (case-insensitive). The variable +does not gate running tasks or lease renewal after activation. + +Task startup checkpoints completed phases by restore ID and owns the manager +through shutdown, including managers created after lifespan startup. Existing +best-effort recovery dispatches reclaimed work immediately during the hook. +FHA admits new requests after successful restore. + +Submit startup tasks from the existing `after_restore_handler` after rebuilding +dependencies. Capture-time submissions are not queued or replayed; no additional +session-start callback or launch-intent registry is included. Local tests are +not proof of hosted golden restoration. See the [design specification](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-core/docs/memory-snapshot-task-integration.md) +for lifecycle ordering, launcher compatibility requirements, validation, and +the selected startup-submission flow. + ## Getting started ### Install the package @@ -23,6 +51,7 @@ OpenTelemetry tracing with Azure Monitor and OTLP exporters is included by defau `AgentServerHost` is the host process for Azure AI Hosted Agent containers. It provides: - **Health probe** — `GET /readiness` returns `200 OK` when the server is ready. +- **Snapshot lifecycle** — Private platform hooks prepare a process for capture and restore session-specific state. - **Graceful shutdown** — On `SIGTERM` the server drains in-flight requests (default 30 s timeout) before exiting. - **OpenTelemetry tracing** — Automatic span creation with Azure Monitor and OTLP export when configured. - **Hypercorn ASGI server** — Serves on `0.0.0.0:${PORT:-8088}` with HTTP/1.1. @@ -90,6 +119,92 @@ async def call_toolbox(query: str) -> str: return resp.text # the toolbox resolved the caller from the call ID and acted as that user ``` +### Draft original-subject model attribution + +`get_request_context().original_subject` carries a separate immutable snapshot +of the proposed original-subject `oid`, `tid`, and `idtype` fields. Responses, +Invocations (HTTP, streaming, and WebSocket), and native Activity consume the +same metadata. The existing user-state identifier and `platform_headers()` +behavior are unchanged. + +This is a draft contract for owner review, not an enabled budget feature. +The inbound original-subject header is an RFC 9651 Structured Fields dictionary: + +```text +x-ms-original-subject: oid="", tid="", idtype="user" +``` + +This draft accepts only `oid`, `tid`, and `idtype` string members, in any +order, without parameters. Strings use Structured Fields quoting and escaping, +not JSON or positional CSV. Duplicate header fields or keys, unknown keys, +malformed syntax, and non-string values raise `ValueError`. Missing keys are +omitted, not encoded as spaces or literal `"null"`. A missing header means no +subject; an empty string member remains distinct from a missing member. +The retired three-header draft is not consumed as a fallback. + +For a supported model endpoint whose owner has enabled the contract, pass +`get_request_context().model_attribution_headers()` as **per-call** headers +(for example, `extra_headers` on a model SDK call). Keep the model's existing +credential. Never put a user's tuple in shared client defaults or send it to +external/BYO endpoints, arbitrary URLs, tools, or storage callbacks. + +```python +from azure.ai.agentserver.core import get_request_context + +response = await model_client.responses.create( + model=model_deployment, + input="Hello", + extra_headers=get_request_context().model_attribution_headers(), +) +``` + +The helper supplies the current call ID when available and one custom +`x-ms-foundry-baggage` member, `foundry.original_subject`. That member contains +the **entire quoted dictionary**, percent-encoded once, not three scalar +dimensions. Outbound model attribution is baggage-only: the helper does not +emit `x-ms-original-subject` or its retired aliases. The standalone header is +still accepted on platform-to-container ingress. Do not `json.dumps` the +original-subject value or pre-encode it. + +Pass any existing per-call header dictionary into the helper to merge safely: + +```python +extra_headers = get_request_context().model_attribution_headers( + {"x-ms-foundry-baggage": "foundry.agent.name=example", "x-example-context": "value"} +) +``` + +Each call returns a fresh mapping. Unrelated headers and baggage values keep +their encoding. Case-insensitive competing subject headers, reserved subject +baggage members (including retired scalar dimensions), and stale call IDs are +removed before the current invocation assertions are added. Unrelated baggage +members keep their relative order and duplicates. The subject member comes first +so bounded transport normalization cannot displace it. The helper does not choose +between competing caller or project assertions. Neither the +input dictionary, shared client defaults, nor the context is mutated. Always +merge before sending, not by overlaying the result onto stale headers. + +No model client is patched automatically. There is no Microsoft Agent Framework +(MAF) adapter in this package tree. An integration must forward the helper's +result at its supported per-call model boundary, including streaming calls; +calling the helper alone does not integrate an external framework. +Absent metadata returns only unrelated supplied headers, or an empty mapping +when none remain; partial, empty, or HTTP-unsafe metadata raises `ValueError` rather +than silently forwarding an incomplete tuple. A supplied subject also requires +the serialized, quoted dictionary to fit 256 characters and the final merged +baggage header to fit 1024 characters. Oversized output raises `ValueError` +instead of forwarding attribution that exceeds the transport bounds. +These limits do not truncate unrelated baggage or apply to subject-free calls. +Non-user and unknown classifications are preserved for receiver-side policy, +not treated as users. +Header assertions are not identity proof: receiver provenance validation, +invocation binding, wire names, and missing-metadata policy still require +owner agreement. Durable restore and detached execution are not supported by +this draft. It does not change authentication, billing, or enforcement. +Supported model transport and billing adapters must accept baggage-only +attribution before release or adoption. Keep receiver-side original-user +selection disabled during rollout. + ### Subclassing AgentServerHost For custom protocol implementations, subclass `AgentServerHost` and add routes: @@ -113,6 +228,30 @@ app = MyAgentHost() app.run() ``` +### Readiness handler + +By default, `GET /readiness` returns `200 OK` with +`{"status": "healthy"}`. Register a readiness handler to replace the default +response with application-specific readiness logic: + +```python +from azure.ai.agentserver.core import AgentServerHost +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +app = AgentServerHost() + +@app.readiness_handler +async def readiness(request: Request) -> Response: + if await dependencies_ready(): + return JSONResponse({"status": "healthy"}) + + return JSONResponse({"status": "not_ready"}, status_code=503) +``` + +When no handler is registered, the built-in healthy response remains +unchanged. + ### Shutdown handler Register a cleanup function that runs during graceful shutdown: @@ -126,6 +265,76 @@ async def on_shutdown(): pass ``` +### Memory snapshot lifecycle handlers + +The host always registers the private platform routes +`POST /_agent/before-snapshot` and `POST /_agent/after-restore`, with equivalent +aliases at `POST /_before_snapshot` and `POST /_after_restore`. All four routes +are processed by the framework, including restore validation and environment +and configuration hydration. Registering application handlers is optional; +when absent, only the application callbacks are skipped. Use the hooks to +release connections that must not be copied into a memory snapshot and rebuild +them for the restored session: + +```python +from azure.ai.agentserver.core import AgentServerHost, AgentSessionContext + +app = AgentServerHost() + +@app.before_snapshot_handler +async def before_snapshot(): + await database.close() + +@app.after_restore_handler +async def after_restore(context: AgentSessionContext): + # context.session_env_overrides has already been applied to os.environ. + await database.connect() +``` + +Lifecycle calls are serialized and idempotent. A repeated `after-restore` +request with the same `restore_id` does not invoke the handler again, while a +new `restore_id` for the same session starts a new restore cycle. The server +rejects attempts to reuse a restored process for a different session. + +When the host's protocols request task execution, the SDK prepares a session-bound +TaskManager before the application `after_restore_handler`. Invocations uses +`set_resilient_tasks_enabled(True)`; Responses independently uses +`resilient_background=True` or `steerable_conversations=True`. Rebuild dependencies first, +then submit `@task`/`@multi_turn_task` work using the normal task APIs: + +```python +@app.after_restore_handler +async def after_restore(context: AgentSessionContext): + await database.connect() + await startup_work.start(input={"session_id": context.session_id}) +``` + +Here `startup_work` is an application's decorated task method. Automatic initial +recovery and the periodic scanner start after the handler returns; HTTP 200 waits +for that initialization, not for background work to finish. `.run()` is also +available for bounded work, but awaiting long-running completion can exceed the +restore timeout. A failed hook can be retried: reconcile unfinished task IDs and +persist completion state if startup must run only once per logical session. +Request routes retain their own protocol's opt-out even on a mixed host. The +restore handler uses the owning host's runtime if any protocol requests one. +The Invocations switch does not disable an enabled Responses option, and +ordinary cold startup does not invoke the restore handler. + +Protocol-specific hosts can override the protected `_before_snapshot` and +`_after_restore` methods to extend the lifecycle before application callbacks +run. The host still performs validation, session environment and configuration +hydration, serialization, rollback, and idempotency, so subclass extensions +cannot accidentally omit those required steps. Overrides should call the +corresponding `super()` method first so multiple protocol layers compose +cooperatively: + +```python +class ProtocolAgentHost(AgentServerHost): + async def _after_restore(self, context: AgentSessionContext): + await super()._after_restore(context) + await self._rebuild_protocol_state(context) +``` + ### Durable state storage `FoundryStateStore` is a durable, server-backed key-value store for agent state @@ -161,6 +370,13 @@ for a runnable end-to-end example. ### Configuring tracing +The default observability setup stamps every OpenTelemetry span and log record +with a `microsoft.foundry = "True"` attribute, including telemetry from +protocol packages and underlying frameworks. Conflicting values for this +attribute are overridden; the existing `gen_ai.provider.name` attribute is +left untouched. Custom observability callbacks that replace the default setup +are responsible for configuring their own enrichment. + Tracing is enabled automatically when an Application Insights connection string is available: ```python diff --git a/sdk/agentserver/azure-ai-agentserver-core/api.md b/sdk/agentserver/azure-ai-agentserver-core/api.md index fce3fa0df5d1..cd533cad1c2a 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/api.md +++ b/sdk/agentserver/azure-ai-agentserver-core/api.md @@ -41,6 +41,10 @@ namespace azure.ai.agentserver.core def azure.ai.agentserver.core.flush_spans(timeout_millis: int = 5000) -> None: ... + @experimental + async def azure.ai.agentserver.core.flush_spans_async:async(timeout_millis: int = 5000) -> None: ... + + def azure.ai.agentserver.core.get_request_context() -> FoundryAgentRequestContext: ... @@ -56,6 +60,7 @@ namespace azure.ai.agentserver.core def azure.ai.agentserver.core.resolve_state_subdir(name: str) -> Path: ... + @experimental def azure.ai.agentserver.core.schedule_flush_spans(timeout_millis: int = 5000) -> None: ... @@ -65,9 +70,6 @@ namespace azure.ai.agentserver.core def azure.ai.agentserver.core.set_request_context(context: FoundryAgentRequestContext) -> Token[FoundryAgentRequestContext]: ... - async def azure.ai.agentserver.core.flush_spans_async:async(timeout_millis: int = 5000) -> None: ... - - async def azure.ai.agentserver.core.trace_stream:async(iterator: AsyncIterable[StreamContent], span: Any) -> AsyncIterator[StreamContent]: ... @@ -119,6 +121,12 @@ namespace azure.ai.agentserver.core **kwargs: kwargs ) -> None: ... + def after_restore_handler(self, fn: Callable[[AgentSessionContext], Awaitable[None]]) -> Callable[[AgentSessionContext], Awaitable[None]]: ... + + def before_snapshot_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: ... + + def readiness_handler(self, fn: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]: ... + def register_pre_shutdown_callback(self, fn: Callable[[], None]) -> None: ... def register_server_version(self, version_segment: str) -> None: ... @@ -138,8 +146,34 @@ namespace azure.ai.agentserver.core def shutdown_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: ... + @experimental + @dataclass(frozen=True, init=False) + class azure.ai.agentserver.core.AgentSessionContext: + restore_id: str + session_env_overrides: Mapping[str, str] + session_id: str + + def __delattr__() -> None: ... + + def __eq__() -> None: ... + + def __hash__() -> None: ... + + def __init__( + self, + session_id: str, + restore_id: str, + session_env_overrides: Optional[Mapping[str, str]] = None + ) -> None: ... + + def __repr__() -> None: ... + + def __setattr__() -> None: ... + + class azure.ai.agentserver.core.FoundryAgentRequestContext: call_id: str | None + original_subject: FoundryOriginalSubject | None session_id: str | None user_id: str | None @@ -147,13 +181,42 @@ namespace azure.ai.agentserver.core self, *, call_id: str | None = ..., + original_subject: FoundryOriginalSubject | None = ..., session_id: str | None = ..., user_id: str | None = ... ) -> None: ... + @experimental + def model_attribution_headers(self, headers: Mapping[str, str] | None = None) -> dict[str, str]: ... + def platform_headers(self) -> dict[str, str]: ... + @experimental + @dataclass(frozen=True, repr=False) + class azure.ai.agentserver.core.FoundryOriginalSubject: + idtype: Optional[str] + oid: Optional[str] + tid: Optional[str] + + def __delattr__() -> None: ... + + def __eq__() -> None: ... + + def __hash__() -> None: ... + + def __init__( + oid: str | None, + tid: str | None, + idtype: str | None + ) -> None: ... + + def __setattr__() -> None: ... + + @classmethod + def from_headers(cls, headers: Mapping[str, str]) -> FoundryOriginalSubject | None: ... + + class azure.ai.agentserver.core.InboundRequestLoggingMiddleware: async def __call__( @@ -622,11 +685,9 @@ namespace azure.ai.agentserver.core.tasks ) -> Callable[[Callable[[TaskContext[Input]], Awaitable[Output]]], MultiTurnTask[Input, Output]]: ... - @experimental def azure.ai.agentserver.core.tasks.resilient_tasks_enabled() -> bool: ... - @experimental def azure.ai.agentserver.core.tasks.set_resilient_tasks_enabled(value: bool = True) -> None: ... @@ -651,7 +712,6 @@ namespace azure.ai.agentserver.core.tasks ) -> Callable[[Callable[[TaskContext[Input]], Awaitable[Output]]], Task[Input, Output]]: ... - @experimental class azure.ai.agentserver.core.tasks.InputTooLarge(ValueError): def __init__( @@ -661,7 +721,6 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... - @experimental class azure.ai.agentserver.core.tasks.LastInputIdPreconditionFailed(TaskPreconditionFailed): def __init__( @@ -674,7 +733,6 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... - @experimental class azure.ai.agentserver.core.tasks.MultiTurnTask(Generic[Input, Output]): property name: str # Read-only @@ -713,7 +771,6 @@ namespace azure.ai.agentserver.core.tasks ) -> TaskRun[Output]: ... - @experimental class azure.ai.agentserver.core.tasks.RetryPolicy: def __eq__(self, other: object) -> bool: ... @@ -772,7 +829,6 @@ namespace azure.ai.agentserver.core.tasks ) -> bool: ... - @experimental class azure.ai.agentserver.core.tasks.SteeringQueueFull(RuntimeError): def __init__( @@ -782,7 +838,6 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... - @experimental class azure.ai.agentserver.core.tasks.Task(Generic[Input, Output]): def __init__( @@ -814,7 +869,6 @@ namespace azure.ai.agentserver.core.tasks ) -> TaskRun[Output]: ... - @experimental class azure.ai.agentserver.core.tasks.TaskCancelled(Exception): def __init__( @@ -826,7 +880,6 @@ namespace azure.ai.agentserver.core.tasks def __str__(self) -> str: ... - @experimental class azure.ai.agentserver.core.tasks.TaskConflictError(RuntimeError): def __init__( @@ -837,7 +890,6 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... - @experimental class azure.ai.agentserver.core.tasks.TaskContext(Generic[Input]): property pending_input_count: int # Read-only @@ -860,7 +912,6 @@ namespace azure.ai.agentserver.core.tasks async def exit_for_recovery(self) -> Any: ... - @experimental class azure.ai.agentserver.core.tasks.TaskDeferred(Exception): def __init__( @@ -884,7 +935,6 @@ namespace azure.ai.agentserver.core.tasks key "type": Literal["exhausted_retries"] - @experimental class azure.ai.agentserver.core.tasks.TaskFailed(Exception): error: Union[TaskErrorDict, TaskExhaustedRetriesErrorDict] @@ -896,11 +946,9 @@ namespace azure.ai.agentserver.core.tasks ) -> None: ... - @experimental class azure.ai.agentserver.core.tasks.TaskManagerNotInitialized(RuntimeError): - @experimental class azure.ai.agentserver.core.tasks.TaskRun(Generic[Output]): implements Awaitable property is_queued: bool # Read-only diff --git a/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml index e8e2da205e8b..fbfc493229c5 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-core/api.metadata.yml @@ -1,4 +1,4 @@ -apiMdSha256: ce207cb151be04811fcfd4383d1626538f8feff1097ce0693152275ee14ab4f3 -packageVersion: 2.2.0b2 +apiHash: 505acf6857339c39d15c27c15d35660dfce1fd70080cb5021011e9031a41eb0f +packageVersion: 2.3.1b1 parserVersion: 0.3.31 -pythonVersion: 3.12.3 +pythonVersion: 3.12.10 diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py index f5e625daf929..eb214322e20e 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py @@ -16,9 +16,11 @@ from ._config import AgentConfig, resolve_state_subdir from ._errors import create_error_response from ._experimental import experimental +from ._lifecycle import AgentSessionContext from ._middleware import InboundRequestLoggingMiddleware from ._request_context import ( FoundryAgentRequestContext, + FoundryOriginalSubject, get_request_context, reset_request_context, set_request_context, @@ -41,11 +43,13 @@ __all__ = [ "AgentConfig", + "AgentSessionContext", "AgentServerHost", "MiddlewareFactory", "StreamContent", "InboundRequestLoggingMiddleware", "FoundryAgentRequestContext", + "FoundryOriginalSubject", "RequestIdMiddleware", "build_server_version", "configure_observability", diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_base.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_base.py index 19eba248380b..1b46c1250f65 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_base.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_base.py @@ -3,6 +3,7 @@ # --------------------------------------------------------- import asyncio # pylint: disable=do-not-import-asyncio import contextlib +import json import logging import os import signal @@ -12,28 +13,58 @@ Awaitable, Callable, ) -from typing import Any, MutableMapping, Optional +from typing import TYPE_CHECKING, Any, MutableMapping, Optional from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import JSONResponse, Response from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send from . import _config, _tracing -from ._middleware import InboundRequestLoggingMiddleware +from ._errors import create_error_response +from ._lifecycle import AgentSessionContext, _LifecycleRollback, _LifecycleState +from ._middleware import InboundRequestLoggingMiddleware, _TaskManagerMiddleware +from ._request_context import ( + FoundryAgentRequestContext, + get_request_context, + reset_request_context, + set_request_context, +) from ._request_id import RequestIdMiddleware as _RequestIdMiddleware from ._server_version import build_server_version from ._types import MiddlewareFactory, P from ._version import VERSION as _CORE_VERSION +if TYPE_CHECKING: + from .tasks._manager import TaskManager + logger = logging.getLogger("azure.ai.agentserver") # Pre-built health-check response to avoid per-request allocation. _HEALTHY_BODY = b'{"status":"healthy"}' _NOT_SET = "(not set)" +_BEFORE_SNAPSHOT_PATH = "/_agent/before-snapshot" +_AFTER_RESTORE_PATH = "/_agent/after-restore" +_BEFORE_SNAPSHOT_ALIAS_PATH = "/_before_snapshot" +_AFTER_RESTORE_ALIAS_PATH = "/_after_restore" +_SESSION_ID_ENV = "FOUNDRY_AGENT_SESSION_ID" +_AGENT_NAME_ENV = "FOUNDRY_AGENT_NAME" +_SESSION_GUID_ENV = "FOUNDRY_AGENT_SESSION_GUID" +_MEMORY_SNAPSHOT_ENV = "FOUNDRY_MEMORY_SNAPSHOT_ENABLED" +_MISSING_ENV = object() + + +def _read_memory_snapshot_enabled() -> bool: + raw = os.environ.get(_MEMORY_SNAPSHOT_ENV) + if raw is None: + return False + value = raw.strip().lower() + if value not in ("true", "false"): + raise ValueError(f"{_MEMORY_SNAPSHOT_ENV} must be 'true' or 'false' when set") + return value == "true" def _read_task_manager_shutdown_grace() -> float: @@ -102,6 +133,23 @@ def _resilient_tasks_enabled() -> bool: return resilient_tasks_enabled() +def _get_active_task_manager() -> Any: + try: + from .tasks._exceptions import ( # pylint: disable=import-outside-toplevel + TaskManagerNotInitialized, + ) + from .tasks._manager import ( # pylint: disable=import-outside-toplevel + get_task_manager, + ) + except ImportError: + return None + + try: + return get_task_manager() + except TaskManagerNotInitialized: + return None + + def _mask_uri(uri: str) -> str: """Return only the scheme and host of a URI, hiding path/query/credentials. @@ -160,13 +208,14 @@ async def _send_with_header(message: MutableMapping[str, Any]) -> None: _SENTINEL_ACCESS_LOG = object() -class AgentServerHost(Starlette): +class AgentServerHost(Starlette): # pylint: disable=too-many-instance-attributes """Agent server host framework for Azure AI Hosted Agent containers. A :class:`~starlette.applications.Starlette` subclass providing the protocol-agnostic infrastructure required by all hosted agent containers: - Health probe (``GET /readiness``) + - Memory snapshot lifecycle hooks - Graceful shutdown handling (SIGTERM, configurable timeout) - OpenTelemetry tracing with Azure Monitor and OTLP exporters - Hypercorn-based ASGI server with HTTP/1.1 @@ -237,6 +286,15 @@ def __init__( # pylint: disable=too-many-statements ) -> None: # Shutdown handler slot (server-level lifecycle) ------------------- self._shutdown_fn: Optional[Callable[[], Awaitable[None]]] = None + self._readiness_fn: Optional[Callable[[Request], Awaitable[Response]]] = None + self._lifecycle = _LifecycleState() + self._task_manager_requirements: list[Callable[[], bool]] = kwargs.pop( + "_task_manager_requirements", [_resilient_tasks_enabled] + ) + self._task_manager: Optional["TaskManager"] = None + self._task_manager_binding: tuple[str, str, str] | None = None + self._task_manager_lock = asyncio.Lock() + self._task_manager_stopping = False # Pre-shutdown callbacks invoked SYNCHRONOUSLY from the # SIGTERM signal handler — before Hypercorn's graceful drain # begins. Used by responses to set ``_shutdown_requested`` early so @@ -280,7 +338,8 @@ def __init__( # pylint: disable=too-many-statements # Build lifespan context manager @contextlib.asynccontextmanager - async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF029 + async def _run_lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF029 + self._task_manager_stopping = False logger.info("AgentServerHost started") # --- Startup configuration logging --- @@ -309,117 +368,116 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF protocols, ) - # --- Resilient task manager initialization --- - # - # The resilient ``TaskManager`` is CONSTRUCTED ONLY when resilient - # tasks were explicitly enabled via - # ``set_resilient_tasks_enabled(True)``. Recovery (and the durable - # task subsystem as a whole) is strictly opt-in: a host that does - # not set the switch pays nothing — no manager, no provider client, - # no task-store call, no recovery loop. - # - # When the switch is OFF the manager is NOT installed, so - # ``get_task_manager()`` raises ``TaskManagerNotInitialized``. - # Callers that route through the manager (e.g. the responses - # protocol's ``store=true`` path) are expected to SWALLOW that - # signal and degrade to non-durable, in-process execution — the - # response still runs and persists, it simply is not crash- - # recoverable. Declaring a durable task (``@task`` / - # ``@multi_turn_task``) does NOT implicitly turn the subsystem on; - # an app that wants durable tasks / recovery must set the switch. - task_manager = None - if _resilient_tasks_enabled(): - # Resilient tasks were explicitly enabled. Construct the manager - # and run startup recovery. If EITHER fails, durability was - # explicitly requested, so fail the lifespan (fail-fast at boot) - # rather than start a server that would silently run store=true - # work non-durably. This guarantees that in a running deployment - # "enabled" always implies a live manager. - from .tasks._manager import ( # pylint: disable=import-outside-toplevel - TaskManager, - set_task_manager, - ) - - task_manager = TaskManager( - config=cfg, - shutdown_event=asyncio.Event(), - shutdown_grace_seconds=_read_task_manager_shutdown_grace(), - ) - set_task_manager(task_manager) + snapshot_mode = _read_memory_snapshot_enabled() + if self._task_manager_required() and not snapshot_mode: try: - await task_manager.startup() + await self._activate_task_manager() except BaseException: - # ``set_task_manager`` above already installed the manager - # globally, but startup failed and the lifespan will now - # abort before the shutdown block runs. Best-effort tear the - # partially-started manager down and clear the singleton so a - # failed/half-initialized manager is not left visible through - # ``get_task_manager()``; then re-raise to fail the lifespan. try: - await task_manager.shutdown() + await self._shutdown_task_manager() except Exception: # pylint: disable=broad-exception-caught logger.warning("Error shutting down TaskManager after startup failure", exc_info=True) - set_task_manager(None) - task_manager = None raise - logger.info("TaskManager initialized with startup recovery") + elif self._task_manager_required(): + logger.info( + "TaskManager deferred until after-restore (memory_snapshot_enabled=%s)", + snapshot_mode, + ) else: logger.info( - "TaskManager NOT initialized (resilient tasks disabled; enable via " - "set_resilient_tasks_enabled(True)). Durable tasks and crash recovery " + "TaskManager NOT initialized (no protocol requested task execution). " + "Durable tasks and crash recovery " "are inactive; store=true work degrades to non-durable execution. " "tasks_declared=%s", _has_registered_tasks(), ) - yield - - # --- SHUTDOWN: runs once when the server is stopping --- - logger.info( - "AgentServerHost shutting down (graceful timeout=%ss)", - self._graceful_shutdown_timeout, - ) - - # Run on_shutdown FIRST so the responses layer's - # ``handle_shutdown`` can set ``_shutdown_requested`` and signal - # cancellation BEFORE the TaskManager waits its grace period. - # Without this, Row 3 (foreground) handlers can race against - # Hypercorn's client-connection close — the disconnect-poll loop - # stamps ``CLIENT_CANCELLED`` instead of ``SHUTTING_DOWN`` and - # B11 emits a cancelled terminal instead of failed. - if self._graceful_shutdown_timeout == 0: - logger.info("Graceful shutdown drain period disabled (timeout=0)") - else: - try: - await asyncio.wait_for( - self._dispatch_shutdown(), - timeout=self._graceful_shutdown_timeout, - ) - except asyncio.TimeoutError: - logger.warning( - "on_shutdown did not complete within %ss timeout", - self._graceful_shutdown_timeout, - ) - except Exception: # pylint: disable=broad-exception-caught - logger.warning("Error in on_shutdown", exc_info=True) - - # Shutdown task manager AFTER on_shutdown so resilient handlers - # have had time to checkpoint via the responses layer's - # ``handle_shutdown``. - if task_manager is not None: + try: + yield + finally: + self._task_manager_stopping = True + manager = self._task_manager + if manager is not None and self._lifecycle.pending_rollback is not None: + # Protocol shutdown can wake workers before serialized manager cleanup. + manager._preserve_tasks_for_shutdown() # pylint: disable=protected-access + logger.info( + "AgentServerHost shutting down (graceful timeout=%ss)", + self._graceful_shutdown_timeout, + ) + # Protocol shutdown must signal handlers before manager draining. + if self._graceful_shutdown_timeout == 0: + logger.info("Graceful shutdown drain period disabled (timeout=0)") + else: + try: + await asyncio.wait_for( + self._dispatch_shutdown(), + timeout=self._graceful_shutdown_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "on_shutdown did not complete within %ss timeout", + self._graceful_shutdown_timeout, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Error in on_shutdown", exc_info=True) + # Activation holds the manager lock across its recovery read, so signal the + # already-created manager before waiting for serialized cleanup. + manager = self._task_manager + if manager is not None: + manager._signal_shutdown() # pylint: disable=protected-access try: - await task_manager.shutdown() - from .tasks._manager import ( # pylint: disable=import-outside-toplevel - set_task_manager as _clear_manager, - ) - - _clear_manager(None) - logger.info("TaskManager shut down") + await self._shutdown_task_manager() except Exception: # pylint: disable=broad-exception-caught logger.warning("Error shutting down TaskManager", exc_info=True) - # Merge routes: subclass routes (if any) + health endpoint - all_routes: list[Any] = list(routes or []) + @contextlib.asynccontextmanager + async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]: + from .tasks._manager import _task_manager_scope # pylint: disable=import-outside-toplevel + + with _task_manager_scope(lambda: self._task_manager): + try: + async with _run_lifespan(app): + yield + finally: + from .tasks._manager import _clear_task_manager # pylint: disable=import-outside-toplevel + + _clear_task_manager(self._task_manager) + + async def _before_snapshot_route(request: Request) -> Response: + return await AgentServerHost._before_snapshot_endpoint(self, request) + + async def _after_restore_route(request: Request) -> Response: + return await AgentServerHost._after_restore_endpoint(self, request) + + # Bind reserved routes to the framework implementation so subclasses + # can extend only through the protected lifecycle hooks. + all_routes: list[Any] = [ + Route( + _BEFORE_SNAPSHOT_PATH, + _before_snapshot_route, + methods=["POST"], + name="before_snapshot", + ), + Route( + _AFTER_RESTORE_PATH, + _after_restore_route, + methods=["POST"], + name="after_restore", + ), + Route( + _BEFORE_SNAPSHOT_ALIAS_PATH, + _before_snapshot_route, + methods=["POST"], + name="before_snapshot_alias", + ), + Route( + _AFTER_RESTORE_ALIAS_PATH, + _after_restore_route, + methods=["POST"], + name="after_restore_alias", + ), + ] + all_routes.extend(routes or []) all_routes.append( Route("/readiness", self._readiness_endpoint, methods=["GET"], name="readiness"), ) @@ -429,6 +487,7 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF routes=all_routes, lifespan=_lifespan, middleware=[ + Middleware(_TaskManagerMiddleware, get_manager=lambda: self._task_manager), Middleware(InboundRequestLoggingMiddleware), # type: ignore[arg-type] Middleware( # type: ignore[arg-type] _PlatformHeaderMiddleware, @@ -447,6 +506,16 @@ async def _lifespan(_app: Starlette) -> AsyncGenerator[None, None]: # noqa: RUF self.add_middleware(TraceContextMiddleware) + def _register_task_callbacks(self, manager: "TaskManager") -> None: + """Bind protocol recovery callbacks before the manager's startup scan. + + :param manager: This host's task manager. + :type manager: ~azure.ai.agentserver.core.tasks._manager.TaskManager + """ + + def _task_manager_required(self) -> bool: + return any(required() for required in self._task_manager_requirements) + def add_middleware( self, middleware_class: MiddlewareFactory[P], @@ -465,6 +534,127 @@ def add_middleware( """ super().add_middleware(middleware_class, *args, **kwargs) + async def _activate_task_manager(self, restore_id: str | None = None, *, prepare_only: bool = False) -> None: + if not self._task_manager_required(): + return + from .tasks._exceptions import ( + TaskManagerNotInitialized, + ) # pylint: disable=import-outside-toplevel + from .tasks._manager import ( # pylint: disable=import-outside-toplevel + TaskManager, + get_task_manager, + set_task_manager, + ) + + async with self._task_manager_lock: + if self._task_manager_stopping: + raise RuntimeError("Cannot activate tasks while the host is shutting down") + binding = (self.config.session_id, self.config.session_guid, self.config.agent_name) + try: + installed = get_task_manager() + except TaskManagerNotInitialized: + installed = None + if installed is not None and installed is not self._task_manager: + raise RuntimeError("TaskManager is already owned by another host") + if self._task_manager is not None and ( + self._task_manager._shutdown_event.is_set() # pylint: disable=protected-access + or self._task_manager_binding != binding + or not self._task_manager._provider_configuration_matches() # pylint: disable=protected-access + ): + if self._task_manager._has_live_workers: # pylint: disable=protected-access + raise RuntimeError("Cannot change task runtime binding while workers are running") + await self._task_manager.shutdown(wait_for_workers=True) + # Close before discarding; a failed close keeps the stopped manager for retry. + await self._task_manager._close_owned_provider() # pylint: disable=protected-access + self._task_manager = None + self._task_manager_binding = None + if self._task_manager is None: + self._task_manager = TaskManager( + config=self.config, + shutdown_event=asyncio.Event(), + shutdown_grace_seconds=_read_task_manager_shutdown_grace(), + ) + if restore_id is not None: + self._task_manager._rehydrate_session_state(restore_id) # pylint: disable=protected-access + self._task_manager_binding = binding + set_task_manager(self._task_manager) + elif self._task_manager_binding != binding: + raise RuntimeError("TaskManager is bound to a different session") + self._register_task_callbacks(self._task_manager) + if prepare_only: + self._task_manager._prepare(restore_id) # pylint: disable=protected-access + return + if restore_id is None: + await self._task_manager.startup() + logger.info("TaskManager initialized with startup recovery") + else: + await self._task_manager.startup(restore_id=restore_id) + logger.info( + "TaskManager initialized for session activation (restore_id=%s)", + restore_id, + ) + + async def _shutdown_task_manager(self, *, preserve_recoverable_tasks: bool = False) -> None: + async with self._task_manager_lock: + if self._task_manager is not None: + from .tasks._manager import ( + _clear_task_manager, + ) # pylint: disable=import-outside-toplevel + + manager = self._task_manager + joined = preserve_recoverable_tasks or self._lifecycle.pending_rollback is not None + if joined: + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True) + # Workers are joined; a failed close keeps this manager for a cleanup retry. + await manager._close_owned_provider() # pylint: disable=protected-access + else: + await manager.shutdown() + _clear_task_manager(manager) + self._task_manager = None + self._task_manager_binding = None + if not joined: + await manager._close_owned_provider_after_shutdown() # pylint: disable=protected-access + logger.info("TaskManager shut down") + + async def _quiesce_tasks_before_rollback(self, previous_session_id: str, previous_session_guid: str) -> bool: + async with self._task_manager_lock: + if self._task_manager is None: + return False + manager = self._task_manager + rollback = self._lifecycle.pending_rollback + previous_agent_name = self.config.agent_name if rollback is None else rollback.config.get("agent_name") + compatible_binding = self._task_manager_binding == ( + previous_session_id, + previous_session_guid, + previous_agent_name, + ) + if rollback is not None: + compatible_binding = ( + compatible_binding + and manager._provider_configuration_matches( # pylint: disable=protected-access + rollback.provider_configuration + ) + ) + if ( + not manager._preserve_recoverable_tasks_on_shutdown # pylint: disable=protected-access + and compatible_binding + ): + return False + logger.warning("Stopping task runtime before restoring its previous session binding") + cleanup = asyncio.create_task(self._shutdown_task_manager(preserve_recoverable_tasks=True)) + interrupted = False + while True: + try: + await asyncio.shield(cleanup) + return interrupted + except asyncio.CancelledError: + if cleanup.cancelled(): + raise RuntimeError("Task runtime cleanup was cancelled before binding rollback") from None + interrupted = True + except Exception: + logger.exception("Task runtime cleanup failed; retaining its restored session binding") + raise + # ------------------------------------------------------------------ # Server version (x-platform-server header) # ------------------------------------------------------------------ @@ -511,6 +701,62 @@ def _build_server_version(self) -> str: # Shutdown handler (server-level lifecycle) # ------------------------------------------------------------------ + async def _before_snapshot(self) -> None: + """Extend snapshot preparation in protocol-specific subclasses. + + This hook runs before the application callback registered with + :meth:`before_snapshot_handler`. The host retains responsibility for + serialization, retry handling, and lifecycle state. + + Cooperative subclasses should call ``await super()._before_snapshot()`` + before performing their own work. + """ + + async def _after_restore(self, context: AgentSessionContext) -> None: + """Extend restoration in protocol-specific subclasses. + + This hook runs after mandatory environment and configuration hydration + and before the application callback registered with + :meth:`after_restore_handler`. The host retains responsibility for + validation, rollback, idempotency, and session affinity. + + :param context: Hydrated restored-session context. + :type context: AgentSessionContext + """ + del context + + def before_snapshot_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: + """Register a function to release process state before a memory snapshot. + + The hook is serialized and runs at most once for each snapshot cycle. + + :param fn: Async function called before the platform captures the process. + :type fn: Callable[[], Awaitable[None]] + :return: The original function (unmodified). + :rtype: Callable[[], Awaitable[None]] + """ + self._lifecycle.before_snapshot_fn = fn + return fn + + def after_restore_handler( + self, + fn: Callable[[AgentSessionContext], Awaitable[None]], + ) -> Callable[[AgentSessionContext], Awaitable[None]]: + """Register a function to rebuild process state after snapshot restore. + + Environment overrides are applied before the function is called. + Enabled resilient tasks are prepared for submission inside the handler. + Automatic initial recovery follows the handler so application dependencies + can be rebuilt before recovered tasks execute. + + :param fn: Async function called with the restored session context. + :type fn: Callable[[AgentSessionContext], Awaitable[None]] + :return: The original function (unmodified). + :rtype: Callable[[AgentSessionContext], Awaitable[None]] + """ + self._lifecycle.after_restore_fn = fn + return fn + def shutdown_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: """Register a function as the shutdown handler. @@ -522,6 +768,24 @@ def shutdown_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Aw self._shutdown_fn = fn return fn + def readiness_handler( + self, + fn: Callable[[Request], Awaitable[Response]], + ) -> Callable[[Request], Awaitable[Response]]: + """Register a function to handle readiness probe requests. + + The registered function replaces the default healthy response for + ``GET /readiness``. + + :param fn: Async function accepting the incoming request and returning + the readiness response. + :type fn: Callable[[Request], Awaitable[Response]] + :return: The original function (unmodified). + :rtype: Callable[[Request], Awaitable[Response]] + """ + self._readiness_fn = fn + return fn + def register_pre_shutdown_callback(self, fn: Callable[[], None]) -> None: """Register a synchronous callback to run on SIGTERM signal receipt. @@ -552,6 +816,285 @@ async def _dispatch_shutdown(self) -> None: if self._shutdown_fn is not None: await self._shutdown_fn() + # ------------------------------------------------------------------ + # Snapshot lifecycle endpoints + # ------------------------------------------------------------------ + + @staticmethod + async def _read_json_object(request: Request) -> dict[str, Any]: + """Read and validate a lifecycle request body. + + :param request: Incoming lifecycle request. + :type request: Request + :return: Parsed JSON object. + :rtype: dict[str, Any] + """ + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + raise ValueError("content type must be application/json") + try: + payload = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("body must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError("body must be a JSON object") + return payload + + @staticmethod + def _parse_session_context(payload: dict[str, Any]) -> AgentSessionContext: + """Parse and validate the after-restore session context. + + :param payload: Parsed after-restore request body. + :type payload: dict[str, Any] + :return: Validated session context. + :rtype: AgentSessionContext + """ + raw_context = payload.get("session_context") + if not isinstance(raw_context, dict): + raise ValueError("session_context must be a JSON object") + + session_id = raw_context.get("session_id") + restore_id = raw_context.get("restore_id") + raw_overrides = raw_context.get("session_env_overrides", {}) + if not isinstance(session_id, str) or not session_id or "\0" in session_id: + raise ValueError("session_id must be a non-empty string") + if not isinstance(restore_id, str) or not restore_id: + raise ValueError("restore_id must be a non-empty string") + if not isinstance(raw_overrides, dict) or any( + not isinstance(key, str) or not isinstance(value, str) for key, value in raw_overrides.items() + ): + raise ValueError("session_env_overrides must map strings to strings") + if any(not key or "=" in key or "\0" in key or "\0" in value for key, value in raw_overrides.items()): + raise ValueError("session_env_overrides contains an invalid environment entry") + override_session_id = raw_overrides.get(_SESSION_ID_ENV) + if override_session_id is not None and override_session_id != session_id: + raise ValueError("FOUNDRY_AGENT_SESSION_ID must match session_id") + return AgentSessionContext( + session_id=session_id, + restore_id=restore_id, + session_env_overrides=raw_overrides, + ) + + async def _before_snapshot_endpoint(self, request: Request) -> Response: + try: + await AgentServerHost._read_json_object(request) + except ValueError: + return create_error_response( + "invalid_before_snapshot_request", + "The before-snapshot request is invalid.", + status_code=400, + ) + + async with self._lifecycle.lock: + if self._lifecycle.before_snapshot_completed: + return JSONResponse({"status": "ok"}) + try: + await self._before_snapshot() + if self._lifecycle.before_snapshot_fn is not None: + await self._lifecycle.before_snapshot_fn() + except Exception: # pylint: disable=broad-exception-caught + logger.exception("The before-snapshot lifecycle hook failed") + return create_error_response( + "before_snapshot_failed", + "The before-snapshot hook failed.", + status_code=500, + ) + self._lifecycle.captured_environment_values = { + key: os.environ.get(key) for key in self._lifecycle.applied_environment_variables + } + self._lifecycle.before_snapshot_completed = True + return JSONResponse({"status": "ok"}) + + async def _after_restore_endpoint(self, request: Request) -> Response: + try: + return await AgentServerHost._run_after_restore(self, request) + except Exception: # pylint: disable=broad-exception-caught + logger.error("The after-restore hook failed; unfinished rollback state is retained") + logger.debug("After-restore failure details", exc_info=True) + return create_error_response( + "after_restore_failed", + "The after-restore hook failed.", + status_code=500, + ) + + async def _run_after_restore(self, request: Request) -> Response: # pylint: disable=too-many-statements + from .tasks._manager import TaskManager # pylint: disable=import-outside-toplevel + + try: + payload = await AgentServerHost._read_json_object(request) + context = AgentServerHost._parse_session_context(payload) + session_guid = context.session_env_overrides.get(_SESSION_GUID_ENV) + if session_guid is not None: + _config._validate_session_guid(session_guid, self.config.is_hosted) # pylint: disable=protected-access + except ValueError: + return create_error_response( + "invalid_after_restore_request", + "The after-restore request is invalid.", + status_code=400, + ) + + async with self._lifecycle.lock: + assigned_session_id = self._lifecycle.assigned_session_id + if (self._task_manager_binding is not None and self._task_manager_binding[0] != context.session_id) or ( + assigned_session_id is not None and assigned_session_id != context.session_id + ): + return create_error_response( + "session_mismatch", + "The restored process is already assigned to another session.", + status_code=409, + ) + if assigned_session_id is None: + self._lifecycle.assigned_session_id = context.session_id + + # Finish interrupted cleanup under its original binding before rehydration. + interrupted = await self._quiesce_tasks_before_rollback(self.config.session_id, self.config.session_guid) + AgentServerHost._restore_lifecycle_environment(self) + if interrupted: + raise asyncio.CancelledError + + if context.restore_id in self._lifecycle.completed_restore_ids: + restored = self._lifecycle.restored_session_context + if restored is None: + raise RuntimeError("Completed restore is missing its session context") + await self._activate_task_manager(restored.restore_id) + return JSONResponse({"status": "ok"}) + + effective_environment = dict(context.session_env_overrides) + effective_environment.setdefault(_SESSION_ID_ENV, context.session_id) + environment_variables = self._lifecycle.applied_environment_variables | effective_environment.keys() + previous_environment = {key: os.environ.get(key, _MISSING_ENV) for key in environment_variables} + captured_environment = dict(self._lifecycle.captured_environment_values) + for key, value in previous_environment.items(): + if key not in captured_environment: + captured_environment[key] = None if value is _MISSING_ENV else str(value) + agent_name = ( + effective_environment.get( + _AGENT_NAME_ENV, captured_environment.get(_AGENT_NAME_ENV, os.environ.get(_AGENT_NAME_ENV)) + ) + or "" + ) + session_guid = ( + effective_environment.get( + _SESSION_GUID_ENV, captured_environment.get(_SESSION_GUID_ENV, os.environ.get(_SESSION_GUID_ENV)) + ) + or "" + ) + if ( + self._task_manager is not None + and self._task_manager_binding is not None + and (self._task_manager_binding[1] != session_guid or self._task_manager_binding[2] != agent_name) + and self._task_manager._has_live_workers # pylint: disable=protected-access + ): + guid_changed = self._task_manager_binding[1] != session_guid + return create_error_response( + "session_guid_mismatch" if guid_changed else "agent_mismatch", + ( + "Cannot change session incarnation while task workers are running." + if guid_changed + else "Cannot change agent identity while task workers are running." + ), + status_code=409, + ) + previous_session_id, previous_session_guid = ( + self.config.session_id, + self.config.session_guid, + ) + previous_config = vars(self.config).copy() + task_manager = _get_active_task_manager() + self._lifecycle.pending_rollback = _LifecycleRollback( + previous_environment, + previous_config, + task_manager, + TaskManager._provider_configuration_for(self.config), # pylint: disable=protected-access + ) + + try: + for key in environment_variables: + value = effective_environment.get(key, captured_environment[key]) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + restored_config = _config.AgentConfig.from_env() + if ( + task_manager is not None + and task_manager._has_live_workers # pylint: disable=protected-access + and not task_manager._provider_configuration_matches( # pylint: disable=protected-access + TaskManager._provider_configuration_for(restored_config) # pylint: disable=protected-access + ) + ): + raise RuntimeError("Cannot change task provider while workers are running") + vars(self.config).update(vars(restored_config)) + if task_manager is not None: + task_manager._rehydrate_session_state(context.restore_id) # pylint: disable=protected-access + + current_request_context = get_request_context() + request_context_token = set_request_context( + FoundryAgentRequestContext( + call_id=current_request_context.call_id, + user_id=current_request_context.user_id, + session_id=context.session_id, + ) + ) + try: + await self._after_restore(context) + await self._activate_task_manager(context.restore_id, prepare_only=True) + if self._lifecycle.after_restore_fn is not None: + await self._lifecycle.after_restore_fn(context) + await self._activate_task_manager(context.restore_id) + finally: + reset_request_context(request_context_token) + except asyncio.CancelledError: + await self._quiesce_tasks_before_rollback(previous_session_id, previous_session_guid) + AgentServerHost._restore_lifecycle_environment(self) + raise + except Exception as exc: # pylint: disable=broad-exception-caught + interrupted = await self._quiesce_tasks_before_rollback(previous_session_id, previous_session_guid) + AgentServerHost._restore_lifecycle_environment(self) + if interrupted: + raise asyncio.CancelledError from exc + logger.exception("The after-restore lifecycle hook failed") + return create_error_response( + "after_restore_failed", + "The after-restore hook failed.", + status_code=500, + ) + + AgentServerHost._complete_restore( + self, + context, + captured_environment, + effective_environment, + ) + return JSONResponse({"status": "ok"}) + + def _complete_restore( + self, + context: AgentSessionContext, + captured_environment: dict[str, Optional[str]], + effective_environment: dict[str, str], + ) -> None: + self._lifecycle.restored_session_context = context + self._lifecycle.completed_restore_ids.add(context.restore_id) + self._lifecycle.captured_environment_values = captured_environment + self._lifecycle.applied_environment_variables.update(effective_environment) + self._lifecycle.before_snapshot_completed = False + self._lifecycle.pending_rollback = None + + def _restore_lifecycle_environment(self) -> None: + rollback = self._lifecycle.pending_rollback + if rollback is None: + return + for key, value in rollback.environment.items(): + if value is _MISSING_ENV: + os.environ.pop(key, None) + else: + os.environ[key] = str(value) + vars(self.config).update(rollback.config) + if rollback.task_manager is not None: + rollback.task_manager._rehydrate_session_state() # pylint: disable=protected-access + self._lifecycle.pending_rollback = None + # ------------------------------------------------------------------ # Run helpers # ------------------------------------------------------------------ @@ -659,7 +1202,9 @@ async def _readiness_endpoint(self, request: Request) -> Response: # pylint: di :param request: The incoming Starlette request. :type request: Request - :return: 200 OK response. + :return: The registered handler's response, or the default 200 OK response. :rtype: Response """ + if self._readiness_fn is not None: + return await self._readiness_fn(request) return Response(_HEALTHY_BODY, media_type="application/json") diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py index 1e087c6f212d..73ce1eb68092 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py @@ -20,6 +20,8 @@ from typing_extensions import Self +from ._constants import Constants + # ====================================================================== # Environment variable keys (internal — users access values via AgentConfig) # ====================================================================== @@ -42,10 +44,16 @@ _ENV_WS_KEEPALIVE_INTERVAL = "WS_KEEPALIVE_INTERVAL" _DEFAULT_PORT = 8088 -_DEFAULT_SSE_KEEPALIVE_INTERVAL = 0 _DEFAULT_WS_PING_INTERVAL = 0.0 +def _validate_session_guid(session_guid: str, is_hosted: bool) -> None: + if is_hosted and session_guid and re.fullmatch(r"[0-9a-f]{32}", session_guid) is None: + raise ValueError( + "FOUNDRY_AGENT_SESSION_GUID must be a 32-character lowercase hexadecimal GUID" + ) + + # ====================================================================== # AgentConfig — resolved environment values # ====================================================================== @@ -132,10 +140,7 @@ def from_env(cls) -> Self: is_hosted = bool(os.environ.get(_ENV_FOUNDRY_HOSTING_ENVIRONMENT, "")) session_guid = os.environ.get(_ENV_FOUNDRY_AGENT_SESSION_GUID, "") - if is_hosted and session_guid and re.fullmatch(r"[0-9a-f]{32}", session_guid) is None: - raise ValueError( - "FOUNDRY_AGENT_SESSION_GUID must be a 32-character lowercase hexadecimal GUID" - ) + _validate_session_guid(session_guid, is_hosted) return cls( agent_name=agent_name, @@ -441,7 +446,7 @@ def resolve_sse_keepalive_interval(interval: Optional[int] = None) -> int: """Resolve the SSE keep-alive interval from argument, env var, or default. Resolution order: explicit *interval* → ``SSE_KEEPALIVE_INTERVAL`` env var - → ``0`` (seconds). A value of ``0`` disables keep-alive. + → ``15`` (seconds). A value of ``0`` disables keep-alive. :param interval: Explicitly requested interval in seconds, or None. :type interval: Optional[int] @@ -453,7 +458,7 @@ def resolve_sse_keepalive_interval(interval: Optional[int] = None) -> int: env_val = _parse_int_env(_ENV_SSE_KEEPALIVE_INTERVAL) if env_val is not None: return max(0, env_val) - return _DEFAULT_SSE_KEEPALIVE_INTERVAL + return Constants.DEFAULT_SSE_KEEPALIVE_INTERVAL def resolve_otlp_endpoint() -> Optional[str]: diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py index e39a68bfee63..a9d5e1ee8527 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_experimental.py @@ -15,14 +15,10 @@ from typing_extensions import ParamSpec, TypeGuard -DOCSTRING_TEMPLATE = ".. note:: {0} {1}\n\n" +DOCSTRING_TEMPLATE = ".. note:: {0}\n\n" DOCSTRING_DEFAULT_INDENTATION = 8 -EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class," -EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method," -EXPERIMENTAL_LINK_MESSAGE = ( - "and may change at any time. Please see https://aka.ms/azure-ai-agentserver-experimental " - "for more information." -) +EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class, and may change at any time." +EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method, and may change at any time." DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = "AZURE_AI_AGENTSERVER_DISABLE_EXPERIMENTAL_WARNING" _EXPERIMENTAL_CACHE_KEY_ATTR = "_azure_agentserver_experimental_cache_key" _EXPERIMENTAL_MESSAGE_ATTR = "_azure_agentserver_experimental_message" @@ -67,11 +63,11 @@ def is_class(value: type[T] | Callable[P, T]) -> TypeGuard[type[T]]: def _add_class_docstring(cls: type[T]) -> type[T]: cache_key = f"class:{cls.__module__}.{cls.__qualname__}" - message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" + message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE}" setattr(cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) setattr(cls, _EXPERIMENTAL_MESSAGE_ATTR, message) - doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_CLASS_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_CLASS_MESSAGE) if cls.__doc__: cls.__doc__ = _add_note_to_docstring(cls.__doc__, doc_string) else: @@ -121,7 +117,7 @@ def _add_async_function_docstring( :return: The wrapped coroutine function. :rtype: Callable[P, Coroutine[Any, Any, T]] """ - doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE) if func.__doc__: func.__doc__ = _add_note_to_docstring(func.__doc__, doc_string) else: @@ -130,10 +126,7 @@ def _add_async_function_docstring( @functools.wraps(func) async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: cache_key = f"function:{func.__module__}.{func.__qualname__}" - message = ( - f"Method {func.__module__}.{func.__qualname__}: " - f"{EXPERIMENTAL_METHOD_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" - ) + message = f"Method {func.__module__}.{func.__qualname__}: {EXPERIMENTAL_METHOD_MESSAGE}" if not _should_skip_warning() and not _is_warning_cached(cache_key): module_logger.warning(message) return await func(*args, **kwargs) @@ -142,7 +135,7 @@ async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: def _add_function_docstring(func: Callable[P, T]) -> Callable[P, T]: - doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) + doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE) if func.__doc__: func.__doc__ = _add_note_to_docstring(func.__doc__, doc_string) else: @@ -151,10 +144,7 @@ def _add_function_docstring(func: Callable[P, T]) -> Callable[P, T]: @functools.wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: cache_key = f"function:{func.__module__}.{func.__qualname__}" - message = ( - f"Method {func.__module__}.{func.__qualname__}: " - f"{EXPERIMENTAL_METHOD_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" - ) + message = f"Method {func.__module__}.{func.__qualname__}: {EXPERIMENTAL_METHOD_MESSAGE}" if not _should_skip_warning() and not _is_warning_cached(cache_key): module_logger.warning(message) return func(*args, **kwargs) diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_lifecycle.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_lifecycle.py new file mode 100644 index 000000000000..366a32a0e94f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_lifecycle.py @@ -0,0 +1,70 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Models for platform-directed agent process lifecycle hooks.""" + +import asyncio # pylint: disable=do-not-import-asyncio +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import TYPE_CHECKING, Awaitable, Callable, Optional + +from ._experimental import experimental + +if TYPE_CHECKING: + from .tasks._manager import TaskManager + + +@experimental +@dataclass(frozen=True, init=False) +class AgentSessionContext: + """Session context supplied after a process is restored from a snapshot. + + :param session_id: Opaque identifier for the session served by the process. + :type session_id: str + :param restore_id: Opaque identifier for this process materialization. + :type restore_id: str + :param session_env_overrides: Sparse environment values that differ from + the captured process. + :type session_env_overrides: Mapping[str, str] + """ + + session_id: str + restore_id: str + session_env_overrides: Mapping[str, str] = field(repr=False) + + def __init__( + self, + session_id: str, + restore_id: str, + session_env_overrides: Optional[Mapping[str, str]] = None, + ) -> None: + object.__setattr__(self, "session_id", session_id) + object.__setattr__(self, "restore_id", restore_id) + object.__setattr__( + self, + "session_env_overrides", + MappingProxyType(dict(session_env_overrides or {})), + ) + + +@dataclass +class _LifecycleRollback: + environment: dict[str, object] = field(repr=False) + config: dict[str, object] = field(repr=False) + task_manager: Optional["TaskManager"] + provider_configuration: tuple[str, str | None] = field(repr=False) + + +@dataclass +class _LifecycleState: + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + before_snapshot_fn: Optional[Callable[[], Awaitable[None]]] = None + after_restore_fn: Optional[Callable[[AgentSessionContext], Awaitable[None]]] = None + before_snapshot_completed: bool = False + assigned_session_id: Optional[str] = None + restored_session_context: Optional[AgentSessionContext] = None + completed_restore_ids: set[str] = field(default_factory=set) + captured_environment_values: dict[str, Optional[str]] = field(default_factory=dict) + applied_environment_variables: set[str] = field(default_factory=set) + pending_rollback: Optional[_LifecycleRollback] = None diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_middleware.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_middleware.py index 4fb3fe78a9cd..29ecf3e7a335 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_middleware.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_middleware.py @@ -20,13 +20,64 @@ import logging import time -from typing import Any, MutableMapping +from typing import Any, Callable, MutableMapping from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.middleware import Middleware logger = logging.getLogger("azure.ai.agentserver") +class _TaskManagerMiddleware: + """Scope task access to the host and protocol serving this request.""" + + def __init__(self, app: ASGIApp, *, get_manager: Callable[[], Any]) -> None: + self.app = app + self._get_manager = get_manager + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + from .tasks._manager import _task_manager_scope # pylint: disable=import-outside-toplevel + + with _task_manager_scope(self._get_manager): + await self.app(scope, receive, send) + + +def _task_access_middleware(host: Any, enabled: Callable[[], bool]) -> list[Middleware]: + """Scope task access to this host only when its protocol is enabled. + + :param host: The owning protocol host. + :type host: Any + :param enabled: Whether the protocol permits task execution. + :type enabled: Callable[[], bool] + :return: Protocol-scoped task middleware. + :rtype: list[~starlette.middleware.Middleware] + """ + return [ + Middleware( + _TaskManagerMiddleware, + get_manager=lambda: host._task_manager if enabled() else None, # pylint: disable=protected-access + ) + ] + + +def _task_runtime_middleware(host: Any, kwargs: dict[str, Any], enabled: Callable[[], bool]) -> list[Middleware]: + """Register protocol activation and scope its routes to the host's runtime. + + :param host: The protocol host being constructed. + :type host: Any + :param kwargs: Cooperative host-constructor arguments. + :type kwargs: dict[str, Any] + :param enabled: Whether this protocol requests task execution. + :type enabled: Callable[[], bool] + :return: Middleware for this protocol's routes. + :rtype: list[~starlette.middleware.Middleware] + """ + requirements = list(kwargs.pop("_task_manager_requirements", [])) + requirements.append(enabled) + kwargs["_task_manager_requirements"] = requirements + return _task_access_middleware(host, enabled) + + def _extract_header(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None: """Extract a header value from raw ASGI headers. @@ -147,7 +198,10 @@ async def _send_wrapper(message: MutableMapping[str, Any]) -> None: elapsed_ms = (time.monotonic() - start) * 1000 logger.warning( "Inbound %s %s failed with status 500 in %.1fms%s", - method, path, elapsed_ms, extra_str, + method, + path, + elapsed_ms, + extra_str, ) raise @@ -156,10 +210,18 @@ async def _send_wrapper(message: MutableMapping[str, Any]) -> None: if status_code is not None and status_code >= 400: logger.warning( "Inbound %s %s completed with status %d in %.1fms%s", - method, path, status_code, elapsed_ms, extra_str, + method, + path, + status_code, + elapsed_ms, + extra_str, ) else: logger.info( "Inbound %s %s completed with status %s in %.1fms%s", - method, path, status_code, elapsed_ms, extra_str, + method, + path, + status_code, + elapsed_ms, + extra_str, ) diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py index 545360b39911..8a3c7e8f67c0 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py @@ -27,16 +27,89 @@ from __future__ import annotations from contextvars import ContextVar, Token +from dataclasses import dataclass +import re +from typing import Mapping +from urllib.parse import quote, unquote +from ._experimental import experimental from ._platform_headers import FOUNDRY_CALL_ID __all__ = [ + "FoundryOriginalSubject", "FoundryAgentRequestContext", "get_request_context", "set_request_context", "reset_request_context", ] +# Draft RFC 9651 dictionary profile: only these string members, without parameters. +_ORIGINAL_SUBJECT_HEADER = "x-ms-original-subject" +_FOUNDRY_BAGGAGE_HEADER = "x-ms-foundry-baggage" +_ORIGINAL_SUBJECT_BAGGAGE = "foundry.original_subject" +_MAX_ORIGINAL_SUBJECT_LENGTH = 256 +_MAX_FOUNDRY_BAGGAGE_LENGTH = 1024 +_ORIGINAL_SUBJECT_MEMBER = re.compile(r'(oid|tid|idtype)="((?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*)"') + + +@experimental +@dataclass(frozen=True, repr=False) +class FoundryOriginalSubject: + """Invocation-local original-subject assertions, separate from model authentication. + + These values are not proof of identity. The receiving model service must + establish provenance and invocation binding before using them. This draft + does not change model authorization or enable budget enforcement. + + :ivar oid: Original subject object ID, not the agent or session owner's ID. + :vartype oid: str | None + :ivar tid: Original subject tenant ID. + :vartype tid: str | None + :ivar idtype: Original subject classification, preserved without inference. + :vartype idtype: str | None + """ + + oid: str | None = None + tid: str | None = None + idtype: str | None = None + + @classmethod + def from_headers(cls, headers: Mapping[str, str]) -> FoundryOriginalSubject | None: + """Snapshot the proposed platform dictionary without interpreting user-state IDs. + + Absent headers, omitted keys, and empty string values remain distinct. + Only oid/tid/idtype string members are accepted, in any order. Duplicate + fields/keys, unknown members, parameters, and malformed syntax raise. + + :param headers: Case-insensitive inbound HTTP headers. + :type headers: Mapping[str, str] + :return: Subject assertions, or ``None`` if the header is absent. + :rtype: FoundryOriginalSubject or None + :raises ValueError: The header does not match the draft dictionary profile. + """ + fields = [value for name, value in headers.items() if name.lower() == _ORIGINAL_SUBJECT_HEADER] + if not fields: + return None + if len(fields) != 1: + raise ValueError("Original-subject attribution requires a single header field.") + + remaining = fields[0].strip(" \t") + values: dict[str, str] = {} + while remaining: + member = _ORIGINAL_SUBJECT_MEMBER.match(remaining) + if member is None or member[1] in values: + raise ValueError("Original-subject attribution requires unique oid/tid/idtype string members.") + values[member[1]] = re.sub(r'\\(["\\])', r"\1", member[2]) + remaining = remaining[member.end() :].lstrip(" \t") + if not remaining: + break + if not remaining.startswith(",") or not remaining[1:].strip(" \t"): + raise ValueError("Original-subject attribution requires comma-separated dictionary members.") + remaining = remaining[1:].lstrip(" \t") + if not values: + raise ValueError("Original-subject attribution requires a nonempty dictionary.") + return cls(oid=values.get("oid"), tid=values.get("tid"), idtype=values.get("idtype")) + class FoundryAgentRequestContext: """Platform-supplied, request-scoped identity context. @@ -53,9 +126,11 @@ class FoundryAgentRequestContext: :vartype user_id: str | None :ivar session_id: The resolved session ID for the request, when available. :vartype session_id: str | None + :ivar original_subject: Optional invocation-local attribution assertions. + :vartype original_subject: FoundryOriginalSubject | None """ - __slots__ = ("call_id", "user_id", "session_id") + __slots__ = ("call_id", "user_id", "session_id", "original_subject") def __init__( self, @@ -63,10 +138,85 @@ def __init__( call_id: str | None = None, user_id: str | None = None, session_id: str | None = None, + original_subject: FoundryOriginalSubject | None = None, ) -> None: self.call_id = call_id self.user_id = user_id self.session_id = session_id + self.original_subject = original_subject + + @experimental + def model_attribution_headers(self, headers: Mapping[str, str] | None = None) -> dict[str, str]: + """Build draft headers for an explicitly supported model integration. + + Pass the result as per-call headers, never shared client defaults. Only + use this for a model endpoint whose owner has enabled this contract. + Do not send these assertions to external/BYO endpoints, storage, tools, + or arbitrary URLs. No client is patched automatically. + + Existing headers and unrelated baggage are copied, not mutated. Reserved + subject assertions are replaced case-insensitively, or removed when the + invocation has no subject. The full quoted dictionary is emitted only + in one baggage member, percent-encoded once. Standalone subject headers + are inbound platform metadata and are not forwarded to the model. + + Partial, empty, HTTP-unsafe, or oversized tuples raise instead of silently + becoming an unattributed model call. The quoted tuple is limited to 256 + characters and merged baggage to 1024 when a subject is supplied. Values, + including non-user/unknown classifications, are not normalized here. + Durable restore and detached work have no attribution support in this draft. + + :param headers: Optional existing per-call headers, including Foundry baggage. + :type headers: Mapping[str, str] or None + :return: A fresh per-call header mapping; model credentials are unchanged. + :rtype: dict[str, str] + :raises ValueError: The supplied tuple is incomplete, HTTP-unsafe, or + exceeds the subject or merged baggage transport limit. + """ + result: dict[str, str] = {} + baggage: list[str] = [] + for name, value in (headers or {}).items(): + lowered = name.lower() + if lowered == _FOUNDRY_BAGGAGE_HEADER: + for member in value.split(","): + member = member.strip() + key = unquote(member.split(";", 1)[0].split("=", 1)[0].strip()).lower() + if ( + not member + or key == _ORIGINAL_SUBJECT_BAGGAGE + or key.startswith(_ORIGINAL_SUBJECT_BAGGAGE + ".") + ): + continue + baggage.append(member) + elif ( + lowered != _ORIGINAL_SUBJECT_HEADER + and not lowered.startswith(_ORIGINAL_SUBJECT_HEADER + "-") + and lowered != FOUNDRY_CALL_ID + ): + result[name] = value + subject = self.original_subject + if subject is None: + if baggage: + result[_FOUNDRY_BAGGAGE_HEADER] = ",".join(baggage) + return result + members = [] + for name, subject_value in (("oid", subject.oid), ("tid", subject.tid), ("idtype", subject.idtype)): + if not subject_value or any(character < "!" or character > "~" for character in subject_value): + raise ValueError("Original-subject attribution requires a complete, HTTP-safe tuple.") + escaped = subject_value.replace("\\", "\\\\").replace('"', '\\"') + members.append(f'{name}="{escaped}"') + value = ", ".join(members) + if len(value) > _MAX_ORIGINAL_SUBJECT_LENGTH: + raise ValueError("Original-subject attribution requires a quoted tuple of at most 256 characters.") + # Reserve attribution space before unrelated members can expand during normalization. + baggage.insert(0, f"{_ORIGINAL_SUBJECT_BAGGAGE}={quote(value, safe='')}") + merged_baggage = ",".join(baggage) + if len(merged_baggage) > _MAX_FOUNDRY_BAGGAGE_LENGTH: + raise ValueError("Original-subject attribution requires merged baggage of at most 1024 characters.") + result[_FOUNDRY_BAGGAGE_HEADER] = merged_baggage + if self.call_id is not None: + result[FOUNDRY_CALL_ID] = self.call_id + return result def platform_headers(self) -> dict[str, str]: """Build the platform identity headers to forward on outbound 1P calls. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py index 590f7f0a3a88..851c7cc7fbbc 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py @@ -63,6 +63,7 @@ _ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id" _ATTR_FOUNDRY_PROJECT_ID = "microsoft.foundry.project.id" _ATTR_SESSION_ID = "microsoft.session.id" +_ATTR_MICROSOFT_FOUNDRY = "microsoft.foundry" # Baggage keys consumed by tracing. # Currently, the invocations package sets the session-id baggage key. @@ -74,10 +75,12 @@ _BAGGAGE_INVOCATION_ID = "azure.ai.agentserver.invocation_id" _ATTR_INVOCATION_ID = "azure.ai.agentserver.invocations.invocation_id" +_A2A_INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.A2A" _SERVICE_NAME_VALUE = "azure.ai.agentserver" _GEN_AI_SYSTEM_VALUE = "azure.ai.agentserver" _GEN_AI_PROVIDER_NAME_VALUE = "AzureAI Hosted Agents" +_MICROSOFT_FOUNDRY_VALUE = "True" logger = logging.getLogger("azure.ai.agentserver") @@ -812,6 +815,7 @@ def __init__( self.agent_tenant_id = agent_tenant_id def on_start(self, span: Any, parent_context: Any = None) -> None: + span.set_attribute(_ATTR_MICROSOFT_FOUNDRY, _MICROSOFT_FOUNDRY_VALUE) if self.project_id: span.set_attribute(_ATTR_FOUNDRY_PROJECT_ID, self.project_id) @@ -829,8 +833,9 @@ def on_start(self, span: Any, parent_context: Any = None) -> None: span.set_attribute(_ATTR_INVOCATION_ID, invocation_id) def _on_ending(self, span: Any) -> None: - # Set agent identity attributes at span end so they cannot be - # overwritten by underlying frameworks (e.g. LangChain, Semantic Kernel). + # Set the microsoft.foundry marker and agent identity attributes at span + # end so they cannot be overwritten by underlying frameworks (e.g. + # LangChain, Semantic Kernel). # # Workaround: opentelemetry-sdk sets _end_time before calling # _on_ending, which causes set_attribute() to silently no-op despite the @@ -847,10 +852,15 @@ def _on_ending(self, span: Any) -> None: return try: target = getattr(attrs, "_dict", attrs) + target[_ATTR_MICROSOFT_FOUNDRY] = _MICROSOFT_FOUNDRY_VALUE + scope = getattr(span, "instrumentation_scope", None) + preserve_agent_card_metadata = getattr(scope, "name", "") == _A2A_INSTRUMENTATION_SCOPE if self.agent_name: - target[_ATTR_GEN_AI_AGENT_NAME] = self.agent_name + if not preserve_agent_card_metadata or _ATTR_GEN_AI_AGENT_NAME not in target: + target[_ATTR_GEN_AI_AGENT_NAME] = self.agent_name if self.agent_version: - target[_ATTR_GEN_AI_AGENT_VERSION] = self.agent_version + if not preserve_agent_card_metadata or _ATTR_GEN_AI_AGENT_VERSION not in target: + target[_ATTR_GEN_AI_AGENT_VERSION] = self.agent_version if self.agent_id: target[_ATTR_GEN_AI_AGENT_ID] = self.agent_id if self.agent_blueprint_id: @@ -871,7 +881,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: # pylint: disable=u class _BaggageLogRecordProcessor: - """OTel log record processor that copies W3C Baggage entries into log attributes. + """Enrich OTel log records with Foundry identity and W3C Baggage. Per container-image-spec §6.1, all baggage key-value pairs from the current span context should appear as attributes on every log record @@ -899,6 +909,8 @@ def on_emit(self, log_data: Any) -> None: # pylint: disable=unused-argument if not hasattr(log_data, "log_record") or not log_data.log_record: return + if log_data.log_record.attributes is None: + log_data.log_record.attributes = {} attrs = log_data.log_record.attributes # type: ignore[assignment] ctx = _otel_context.get_current() @@ -907,13 +919,18 @@ def on_emit(self, log_data: Any) -> None: # pylint: disable=unused-argument for key, value in entries.items(): attrs[key] = value # type: ignore[index] + attrs[_ATTR_MICROSOFT_FOUNDRY] = _MICROSOFT_FOUNDRY_VALUE if self.agent_name and _ATTR_GEN_AI_AGENT_NAME not in attrs: attrs[_ATTR_GEN_AI_AGENT_NAME] = self.agent_name if self.agent_version and _ATTR_GEN_AI_AGENT_VERSION not in attrs: attrs[_ATTR_GEN_AI_AGENT_VERSION] = self.agent_version bag_session = _otel_baggage.get_baggage(_BAGGAGE_SESSION_ID, context=ctx) - resolved_session = bag_session or self.session_id + resolved_session = ( + bag_session + or _config.resolve_session_id() + or self.session_id + ) if resolved_session and _ATTR_SESSION_ID not in attrs: attrs[_ATTR_SESSION_ID] = resolved_session except Exception: # pylint: disable=broad-except diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py index 4df7819b5eb2..011770c0cdd9 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "2.2.0" +VERSION = "2.4.0b1" diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_concrete.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_concrete.py index 0ac6f82fa16f..d095309dab8d 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_concrete.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_concrete.py @@ -23,7 +23,7 @@ import time from collections.abc import AsyncIterator, Callable from pathlib import Path -from typing import Any, Optional +from typing import Any, BinaryIO, Optional from ._protocol import ( EventStreamClosedError, @@ -409,7 +409,19 @@ def __aiter__(self) -> "_ReplayIterator": self._attach() return self + def _check_file_writer(self) -> None: + if isinstance(self._owner, FileBackedReplayEventStream): + try: + self._owner._raise_if_write_failed() + except RuntimeError: + self._terminated = True + if self._queue is not None: + self._owner._remove_subscriber(self._queue) + raise + async def __anext__(self) -> Any: + if not self._terminated: + self._check_file_writer() if not self._attached and not self._terminated: self._attach() if self._terminated: @@ -440,6 +452,7 @@ async def __anext__(self) -> Any: assert self._queue is not None try: item = await self._queue.get() + self._check_file_writer() if item is _GONE_SENTINEL: self._terminated = True self._owner._remove_subscriber(self._queue) @@ -547,11 +560,47 @@ def __init__( self._buffer: list[_BufferedEvent] = [] self._highest_cursor: Optional[int] = None self._evictions_since_compaction = 0 - - # Acquire single-writer lock + open file for append (rule 32). + self._lock_fd: Optional[int] = None + self._lock_path: Optional[Path] = None + self._retired_files: list[BinaryIO] = [] + self._write_failed = False + self._deletion_owner: Optional[_FileBackedReplayDeletion] = None + self._file_identity: Optional[os.stat_result] = None + self._backing_removed = False + + initialized = False + try: + self._file = self._open_file() + self._acquire_file_lock() + self._rehydrate() + initialized = True + finally: + if not initialized: + try: + self._cleanup_locks() + except OSError as cleanup_error: + self._state = self._STATE_GONE + self._write_failed = True + self._buffer.clear() + self._evictions_since_compaction = 0 + # Let the registry retain the exact cleanup owner without + # changing the primary or cleanup exception types. + setattr(cleanup_error, "_replay_cleanup_owner", self) + raise + + def _open_file(self) -> BinaryIO: + """Open the replay for append and recovery. + + :return: The replay file handle. + :rtype: BinaryIO + """ self._path.parent.mkdir(parents=True, exist_ok=True) - # Open in append+read mode; fcntl.flock on POSIX, lock-file fallback elsewhere. - self._file = open(self._path, "a+b") # pylint: disable=consider-using-with + if fcntl is None: + self._acquire_lock_file() + return open(self._path, "a+b") # pylint: disable=consider-using-with + + def _acquire_file_lock(self) -> None: + """Acquire the single-writer lock before accessing the replay.""" if fcntl is not None: try: fcntl.flock(self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -563,21 +612,37 @@ def __init__( raise RuntimeError( f"FileBackedReplayEventStream: another process holds the " f"lock on {self._path}" ) from exc - else: - # Windows fallback: best-effort lock-file approach. - lock_path = self._path.with_suffix(self._path.suffix + ".lock") - try: - self._lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR) - self._lock_path = lock_path - except FileExistsError as exc: - self._file.close() - logger.warning("FileBackedReplayEventStream: lock-file contention on %s", self._path) - raise RuntimeError( - f"FileBackedReplayEventStream: another process holds the " f"lock-file on {self._path}" - ) from exc - - # Rehydrate from disk if file already had content (rule 28). - self._rehydrate() + elif self._lock_fd is None: + self._acquire_lock_file() + self._file_identity = os.fstat(self._file.fileno()) + self._check_backing_identity(self._file_identity) + + def _acquire_lock_file(self) -> None: + """Own the Windows sidecar before opening or creating the replay.""" + lock_path = self._path.with_suffix(self._path.suffix + ".lock") + try: + self._lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR) + self._lock_path = lock_path + except FileExistsError as exc: + logger.warning("FileBackedReplayEventStream: lock-file contention on %s", self._path) + raise RuntimeError( + f"FileBackedReplayEventStream: another process holds the lock-file on {self._path}" + ) from exc + + def _check_backing_identity(self, identity: os.stat_result) -> None: + """Reject a locked descriptor that no longer names the backing log. + + :param identity: The owned file descriptor's filesystem identity. + :type identity: os.stat_result + """ + try: + current = self._path.stat() + except FileNotFoundError as exc: + raise _ReplayFileNotFoundError(str(self._path)) from exc + if not os.path.samestat(identity, current): + raise RuntimeError( + f"FileBackedReplayEventStream: backing file changed while acquiring ownership: {self._path}" + ) def _serialize(self, payload: Any, emit_time: float) -> bytes: if self._serializer is not None: @@ -631,7 +696,6 @@ def _rehydrate(self) -> None: # pylint: disable=too-many-statements rec = self._deserialize_record(line) except (json.JSONDecodeError, UnicodeDecodeError) as exc: # Mid-file malformed — RuntimeError at construction (rule 29). - self._cleanup_locks() logger.error("FileBackedReplayEventStream: malformed record at line %d of %s", idx, self._path) raise RuntimeError( f"FileBackedReplayEventStream: malformed record at " f"line {idx} of {self._path}" @@ -652,7 +716,6 @@ def _rehydrate(self) -> None: # pylint: disable=too-many-statements ) continue if "emit_time" not in rec: - self._cleanup_locks() logger.error( "FileBackedReplayEventStream: record at line %d of %s missing 'emit_time' field", idx, self._path ) @@ -668,8 +731,6 @@ def _rehydrate(self) -> None: # pylint: disable=too-many-statements cursor = self._cursor_fn(entry.payload) if self._highest_cursor is None or cursor > self._highest_cursor: self._highest_cursor = cursor - # Apply TTL eviction now (records may have expired since being written). - self._evict_expired() if had_terminal: self._state = self._STATE_CLOSED # Spec 037 #2 — close-time is best-effort: the terminal record @@ -680,7 +741,10 @@ def _rehydrate(self) -> None: # pylint: disable=too-many-statements self._close_time = records[-1]["emit_time"] else: self._close_time = time.time() - self._maybe_auto_transition_to_gone() + # Eviction may compact immediately: publish the observed terminal + # state first so the replacement retains its final marker. + self._evict_expired() + self._maybe_auto_transition_to_gone() # Position file at end for subsequent appends. self._file.seek(0, os.SEEK_END) logger.debug( @@ -693,19 +757,30 @@ def _rehydrate(self) -> None: # pylint: disable=too-many-statements def _cleanup_locks(self) -> None: try: - if fcntl is not None: - fcntl.flock(self._file.fileno(), fcntl.LOCK_UN) - else: + # Closing the file also releases a POSIX flock. Keep each remaining + # resource recorded until its cleanup succeeds so retries are safe. + handle = getattr(self, "_file", None) + if handle is not None and not handle.closed: + handle.close() + while self._retired_files: + retired = self._retired_files[0] + if not retired.closed: + retired.close() + del self._retired_files[0] + if self._lock_fd is not None: os.close(self._lock_fd) + self._lock_fd = None + if self._lock_path is not None: self._lock_path.unlink(missing_ok=True) - except Exception: # pylint: disable=broad-except - pass - try: - self._file.close() - except Exception: # pylint: disable=broad-except - pass + self._lock_path = None + except OSError: + logger.error("FileBackedReplayEventStream: failed to release resources for %s", self._path, exc_info=True) + raise def _evict_expired(self) -> None: + if self._state == self._STATE_GONE: + return + self._raise_if_write_failed() if self._ttl_seconds is None: return now = time.time() @@ -726,38 +801,71 @@ def _compact_on_disk(self) -> None: Lazy compaction (rule 30) — keeps the file bounded across repeated process restarts. """ + if self._state == self._STATE_GONE: + raise EventStreamNotFoundError("stream id is tombstoned") + self._raise_if_write_failed() tmp_path = self._path.with_suffix(self._path.suffix + ".compact") + replaced = False + preparation_error: Optional[Exception] = None try: - with open(tmp_path, "wb") as tmp: - for entry in self._buffer: - tmp.write(self._serialize(entry.payload, entry.emit_time)) - if self._state == self._STATE_CLOSED: - tmp.write(self._serialize_terminal()) - # Atomic replace (POSIX guarantees atomicity on same fs). - os.replace(tmp_path, self._path) - # ``os.replace`` swapped ``self._path`` to a brand-new inode; our - # ``self._file`` handle still points at the old (now-unlinked) - # inode, so every subsequent ``emit``/``close`` write would land in - # the orphaned file and be lost on the next process lifetime (and - # the single-writer ``flock`` would be held on the dead inode). - # Reopen against the live path and re-acquire the lock. Open + lock - # the new handle BEFORE closing the old one so the single-writer - # guarantee is never released across the swap. - old_file = self._file - new_file = open(self._path, "a+b") # pylint: disable=consider-using-with + try: + with open(tmp_path, "wb") as tmp: + for entry in self._buffer: + tmp.write(self._serialize(entry.payload, entry.emit_time)) + if self._state == self._STATE_CLOSED: + tmp.write(self._serialize_terminal()) + os.replace(tmp_path, self._path) + except Exception as error: # pylint: disable=broad-except + preparation_error = error + logger.warning( + "FileBackedReplayEventStream: compaction preparation failed for %s; retaining writer", + self._path, + exc_info=True, + ) + return + replaced = True + self._reopen_compacted_file() + finally: + if not replaced: + try: + tmp_path.unlink(missing_ok=True) + except OSError as cleanup_error: + if preparation_error is not None: + raise cleanup_error from preparation_error + raise + + def _reopen_compacted_file(self) -> None: + """Replace the old inode's writer, or permanently reject further writes.""" + old_file = self._file + new_file: Optional[BinaryIO] = None + ready = False + try: + new_file = open(self._path, "r+b") # pylint: disable=consider-using-with if fcntl is not None: fcntl.flock(new_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + identity = os.fstat(new_file.fileno()) + self._check_backing_identity(identity) new_file.seek(0, os.SEEK_END) self._file = new_file - try: - old_file.close() - except Exception: # pylint: disable=broad-except - pass - except Exception: # pylint: disable=broad-except - try: - tmp_path.unlink(missing_ok=True) - except Exception: # pylint: disable=broad-except - pass + self._file_identity = identity + old_file.close() + ready = True + finally: + if not ready: + self._write_failed = True + if self._file is old_file: + self._file_identity = None + for queue in list(self._subscriber_queues): + queue.put_nowait(_GONE_SENTINEL) + for handle in (old_file, new_file): + if handle is not None and handle is not self._file: + self._retired_files.append(handle) + self._cleanup_locks() + + def _raise_if_write_failed(self) -> None: + """Fail closed after replacement leaves no usable, locked writer.""" + if self._write_failed and self._state != self._STATE_GONE: + raise RuntimeError("FileBackedReplayEventStream: writer unavailable after compaction failure") def _maybe_auto_transition_to_gone(self) -> None: """— close-clock auto-tombstone. @@ -805,6 +913,7 @@ async def emit(self, payload: Any, *, close: bool = False) -> None: async def close(self) -> None: async with self._lock: + self._raise_if_write_failed() if self._state != self._STATE_ACTIVE: return self._file.write(self._serialize_terminal()) @@ -815,6 +924,7 @@ async def close(self) -> None: await self._fanout_terminate() def subscribe(self, *, after: Optional[int] = None) -> AsyncIterator[Any]: + self._raise_if_write_failed() if self._cursor_fn is None: after = None self._evict_expired() @@ -824,6 +934,7 @@ def subscribe(self, *, after: Optional[int] = None) -> AsyncIterator[Any]: return _ReplayIterator(self, after=after) # same iterator shape works async def last_cursor(self) -> Optional[int]: + self._raise_if_write_failed() if self._state == self._STATE_GONE: raise EventStreamNotFoundError("stream id is tombstoned") return self._highest_cursor @@ -833,11 +944,70 @@ async def _on_delete(self) -> None: self._state = self._STATE_GONE self._buffer.clear() await self._fanout_terminate() + if self._backing_removed: + self._cleanup_locks() + return + if self._file_identity is None or (self._file.closed and self._lock_path is None): + self._cleanup_locks() + if self._deletion_owner is None: + try: + self._deletion_owner = _FileBackedReplayDeletion(path=self._path) + except _ReplayFileNotFoundError: + self._backing_removed = True + return + await self._deletion_owner._on_delete() # pylint: disable=protected-access + self._backing_removed = True + return + self._remove_owned_backing() self._cleanup_locks() - try: - self._path.unlink(missing_ok=True) - except Exception: # pylint: disable=broad-except - pass + + def _remove_owned_backing(self) -> None: + """Remove only the owned log, retaining ownership on removal failure.""" + assert self._file_identity is not None + try: + self._check_backing_identity(self._file_identity) + except _ReplayFileNotFoundError: + self._backing_removed = True + return + except RuntimeError: + self._write_failed = True + self._file_identity = None + raise + # POSIX keeps the inode lock through unlink. Windows must close the + # log first, but its sidecar remains owned until removal succeeds. + try: + if self._lock_path is not None and not self._file.closed: + self._file.close() + self._path.unlink(missing_ok=True) + except OSError: + logger.error("FileBackedReplayEventStream: failed to delete %s", self._path, exc_info=True) + raise + self._backing_removed = True + + +class _ReplayFileNotFoundError(FileNotFoundError): + """A replay open or locked-descriptor identity check found no backing log.""" + + +class _FileBackedReplayRestoration(FileBackedReplayEventStream): + """Restore an existing replay without creating an absent backing file.""" + + def _open_file(self) -> BinaryIO: + # Never create an absent log, including when another process removes it. + if fcntl is None: + self._acquire_lock_file() + try: + return open(self._path, "r+b") # pylint: disable=consider-using-with + except FileNotFoundError as exc: + raise _ReplayFileNotFoundError(str(self._path)) from exc + + +class _FileBackedReplayDeletion(_FileBackedReplayRestoration): + """A non-reading, retryable cleanup owner for an existing cold replay.""" + + def _rehydrate(self) -> None: + # Reuse the file-lock and deletion hooks without decoding replay events. + self._state = self._STATE_GONE __all__ = [ diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_registry.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_registry.py index c72bb914d2a3..b0fdc639772f 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_registry.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/streaming/_registry.py @@ -16,7 +16,8 @@ backings. Third-party :class:`EventStream` impls do NOT plug into this registry — they ship their own peer registry. -: ``get(id)`` raises +: ``get(id)`` restores an existing file-backed replay log when needed, but +never creates a log for an absent id. It raises :class:`EventStreamNotFoundError` for ANY id that is not currently a live stream — never registered, explicitly :meth:`delete`d, or close-clock TTL elapsed. The registry retains tombstones for @@ -31,7 +32,9 @@ import asyncio # pylint: disable=do-not-import-asyncio import logging -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional, Union @@ -39,6 +42,9 @@ BroadcastEventStream, FileBackedReplayEventStream, ReplayEventStream, + _FileBackedReplayDeletion, + _FileBackedReplayRestoration, + _ReplayFileNotFoundError, _safe_stream_filename, ) from ._protocol import ( @@ -59,6 +65,14 @@ _DEFAULT_STREAM_TTL_SECONDS = 600.0 +@dataclass +class _LifecycleLock: + """A per-ID lock shared by its active holder and registered waiters.""" + + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + users: int = 0 + + class _StreamsRegistry: """Implementation of the module-level :data:`streams` singleton. @@ -71,13 +85,12 @@ def __init__(self) -> None: # Streams keyed by id; value is either an EventStream # instance OR _TOMBSTONE for destroyed ids. self._slots: dict[str, Union[EventStream, object]] = {} - # Per-id locks for get_or_create atomicity (rule 34). - self._id_locks: dict[str, asyncio.Lock] = {} - # Global lock guarding _slots + _id_locks structural mutations. - self._struct_lock = asyncio.Lock() + # Entries live only while lifecycle operations hold or await the lock. + self._id_locks: dict[str, _LifecycleLock] = {} # Factory closure — set by use_* configurators. Default: # use_in_memory_live per rule 37a (also). self._factory: Callable[[str], EventStream] = lambda _id: BroadcastEventStream() + self._restore: Callable[[str, bool], Optional[EventStream]] = lambda _id, _for_deletion: None # ----- Configurators (sync) ----- @@ -88,6 +101,7 @@ def use_in_memory_live(self) -> None: Suitable when consumers attach before the producer starts. """ self._factory = lambda _id: BroadcastEventStream() + self._restore = lambda _id, _for_deletion: None def use_in_memory_replay( self, @@ -109,6 +123,7 @@ def use_in_memory_replay( :paramtype ttl_seconds: Optional[float] """ self._factory = lambda _id: ReplayEventStream(cursor_fn=cursor_fn, ttl_seconds=ttl_seconds) + self._restore = lambda _id, _for_deletion: None def use_file_backed_replay( self, @@ -163,31 +178,71 @@ def use_file_backed_replay( ttl_seconds = _DEFAULT_STREAM_TTL_SECONDS storage_dir = Path(storage_dir) storage_dir.mkdir(parents=True, exist_ok=True) - self._factory = lambda _id: FileBackedReplayEventStream( - path=storage_dir / _safe_stream_filename(_id), - cursor_fn=cursor_fn, - ttl_seconds=ttl_seconds, - serializer=serializer, - deserializer=deserializer, - ) + + def open_replay(_id: str, backing: type[FileBackedReplayEventStream]) -> EventStream: + path = storage_dir / _safe_stream_filename(_id) + try: + return backing( + path=path, + cursor_fn=cursor_fn, + ttl_seconds=ttl_seconds, + serializer=serializer, + deserializer=deserializer, + ) + except OSError as error: + setattr(error, "_replay_cleanup_id", None) + owner = getattr(error, "_replay_cleanup_owner", None) + if isinstance(owner, FileBackedReplayEventStream) and getattr(owner, "_path", None) == path: + setattr(error, "_replay_cleanup_id", _id) + raise + + self._factory = lambda _id: open_replay(_id, FileBackedReplayEventStream) + + def restore(_id: str, for_deletion: bool) -> Optional[EventStream]: + try: + backing = _FileBackedReplayDeletion if for_deletion else _FileBackedReplayRestoration + return open_replay(_id, backing) + except _ReplayFileNotFoundError: + return None + + self._restore = restore # ----- Lifecycle (async) ----- - async def _get_id_lock(self, id: str) -> asyncio.Lock: - async with self._struct_lock: - lock = self._id_locks.get(id) - if lock is None: - lock = asyncio.Lock() - self._id_locks[id] = lock - return lock + @asynccontextmanager + async def _hold_id_lock(self, id: str) -> AsyncIterator[None]: + """Share a lifecycle lock until its last holder or waiter exits. + + :param id: The stream lifecycle identifier. + :type id: str + :return: A context manager holding the per-ID lock. + :rtype: AsyncIterator[None] + """ + # These structural mutations contain no await: they are atomic within + # the event loop, and cancellation cannot interrupt reference cleanup. + entry = self._id_locks.get(id) + if entry is None: + entry = _LifecycleLock() + self._id_locks[id] = entry + entry.users += 1 + try: + async with entry.lock: + yield + finally: + entry.users -= 1 + if entry.users == 0 and self._id_locks.get(id) is entry: + del self._id_locks[id] async def get(self, id: str) -> EventStream: - """Look up the existing instance for ``id``. + """Look up an existing stream, restoring a persisted replay if present. + + Lookup and restoration share the per-id lifecycle lock with creation + and deletion. An absent replay log is never created by this operation. — every "id is not currently a live stream" condition raises :class:`EventStreamNotFoundError`: - - Unregistered id (never seen). + - Unregistered id with no persisted replay log. - Explicitly :meth:`delete`d id (tombstoned). - Closed stream whose close-clock TTL deadline has elapsed (auto-tombstoned). @@ -197,20 +252,47 @@ async def get(self, id: str) -> EventStream: :return: The live stream instance for ``id``. :rtype: EventStream """ - slot = self._slots.get(id, None) + async with self._hold_id_lock(id): + slot = self._load_existing(id) + if slot is None or slot is _TOMBSTONE: + raise EventStreamNotFoundError(id) + if await self._tombstone_if_close_clock_elapsed(id, slot): + raise EventStreamNotFoundError(id) + return slot # type: ignore[return-value] + + def _load_existing(self, id: str, *, for_deletion: bool = False) -> Union[EventStream, object, None]: + """Load an existing slot or replay while holding the per-id lock. + + :param id: The stream id to look up. + :type id: str + :keyword for_deletion: Open only the backing resources without replay + deserialization, retaining the cleanup owner if deletion fails. + :paramtype for_deletion: bool + :return: An existing stream, tombstone, or ``None``. + :rtype: EventStream | object | None + """ + slot = self._slots.get(id) if slot is None: - raise EventStreamNotFoundError(id) - if slot is _TOMBSTONE: - raise EventStreamNotFoundError(id) - # — opportunistic close-clock check. - # If the stream's internal _maybe_auto_transition_to_gone - # would fire, install the registry tombstone now and raise - # NotFound. This makes the registry-level auto-tombstone - # observable even without an explicit emit/subscribe on the - # instance. - if await self._tombstone_if_close_clock_elapsed(id, slot): - raise EventStreamNotFoundError(id) - return slot # type: ignore[return-value] + try: + slot = self._restore(id, for_deletion) + except OSError as error: + self._retain_cleanup_owner(id, error) + raise + if slot is not None: + self._slots[id] = slot + return slot + + def _retain_cleanup_owner(self, id: str, error: OSError) -> None: + """Retain only the failed constructor's exact owned cleanup resources. + + :param id: The stream lifecycle identifier. + :type id: str + :param error: The constructor cleanup error. + :type error: OSError + """ + owner = getattr(error, "_replay_cleanup_owner", None) + if getattr(error, "_replay_cleanup_id", None) == id and isinstance(owner, FileBackedReplayEventStream): + self._slots[id] = owner async def _tombstone_if_close_clock_elapsed(self, id: str, slot: Any) -> bool: """If the stream's close-clock TTL elapsed, run its @@ -248,6 +330,7 @@ async def _tombstone_if_close_clock_elapsed(self, id: str, slot: Any) -> bool: logger.warning( "EventStream %s: _on_delete cleanup hook failed during auto-tombstone", id, exc_info=True ) + raise self._slots[id] = _TOMBSTONE logger.debug("EventStream %s auto-tombstoned (close-clock TTL elapsed)", id) return True @@ -265,23 +348,21 @@ async def get_or_create(self, id: str) -> EventStream: :return: The cached or newly-created stream instance. :rtype: EventStream """ - # Fast path — already present, not tombstoned - slot = self._slots.get(id, None) - if slot is not None and slot is not _TOMBSTONE: - return slot # type: ignore[return-value] - # Slow path — acquire per-id lock + create - lock = await self._get_id_lock(id) - async with lock: + async with self._hold_id_lock(id): slot = self._slots.get(id, None) if slot is not None and slot is not _TOMBSTONE: return slot # type: ignore[return-value] - instance = self._factory(id) + try: + instance = self._factory(id) + except OSError as error: + self._retain_cleanup_owner(id, error) + raise self._slots[id] = instance logger.debug("EventStream %s created (%s)", id, type(instance).__name__) return instance async def delete(self, id: str) -> None: - """Destroy the stream registered for ``id``. + """Destroy the registered or persisted stream for ``id``. Idempotent — calling on an unregistered or already-destroyed id is a no-op (but still ensures the tombstone is in place so @@ -289,27 +370,21 @@ async def delete(self, id: str) -> None: Cleans up backing resources (e.g. file handles for the file-backed replay backing) before installing the tombstone - / C-STR-FBR-4. + / C-STR-FBR-4. Cold deletion locks an existing log without + deserializing events, so corrupt replay data remains removable. :param id: The stream id to destroy. :type id: str """ - slot = self._slots.get(id, None) - if slot is None: - # Never registered — install tombstone for symmetry - # (the next get(id) raises ``EventStreamNotFoundError``). - # This matches rule 36a's "delete is symmetric with rm -f - # but still leaves a marker" semantics. + async with self._hold_id_lock(id): + slot = self._load_existing(id, for_deletion=True) + if slot is _TOMBSTONE: + return + on_delete = getattr(slot, "_on_delete", None) + if on_delete is not None: + await on_delete() self._slots[id] = _TOMBSTONE - return - if slot is _TOMBSTONE: - return # idempotent - # Invoke private cleanup hook on the bundled impl - on_delete = getattr(slot, "_on_delete", None) - if on_delete is not None: - await on_delete() - self._slots[id] = _TOMBSTONE - logger.debug("EventStream %s deleted", id) + logger.debug("EventStream %s deleted", id) # Module-level singleton — THE public registry. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_client.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_client.py index c219e407fdb2..1bb1d95873b7 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_client.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_client.py @@ -44,6 +44,7 @@ from azure.core.rest import HttpRequest from .._version import VERSION +from ._validation import normalize_list_limit from ._attachments import ( _validate_attachment_count, _validate_attachment_size, @@ -448,6 +449,8 @@ def __init__( ) -> None: self._base_url = f"{project_endpoint.rstrip('/')}/tasks" self._credential = credential + # Set only when the manager created the credential for this provider. + self._owned_credential: AsyncTokenCredential | None = None config: Configuration = Configuration() config.user_agent_policy = UserAgentPolicy(base_user_agent=_USER_AGENT) self._policies: list[Any] = _build_default_policies(credential) @@ -721,7 +724,7 @@ async def list( :paramtype has_error: bool | None :keyword lease_expired: Optional filter for tasks whose lease has expired. :paramtype lease_expired: bool | None - :keyword limit: Page size for cursor pagination. + :keyword limit: Total result limit, clamped to 100; None enumerates all pages. :paramtype limit: int | None :keyword after: Return tasks after this pagination cursor. :paramtype after: str | None @@ -731,12 +734,13 @@ async def list( :paramtype order: str | None :keyword omit_attachment_values: When True, omit attachment values from results. :paramtype omit_attachment_values: bool - :return: All matching tasks across all pages. + :return: Matching tasks, bounded by the explicit limit when supplied. :rtype: list[TaskInfo] """ + result_limit = normalize_list_limit(limit) params: dict[str, str] = { "api-version": _API_VERSION, - "limit": str(limit if limit is not None else 100), + "limit": str(result_limit if result_limit is not None else 100), } if agent_name is not None: params["agent_name"] = agent_name @@ -765,6 +769,7 @@ async def list( params["omit_attachment_values"] = "true" all_tasks: list[TaskInfo] = [] + cursors: set[str] = {after} if after is not None else set() while True: http_request = HttpRequest("GET", self._base_url, params=params) response = await self._send(http_request) @@ -772,17 +777,32 @@ async def list( _raise_classified(response, method="GET", url=self._base_url) data = _parse_json_body(response, method="GET", url=self._base_url) items: list[dict[str, Any]] = data.get("data", data.get("items", [])) + if result_limit is not None: + items = items[: result_limit - len(all_tasks)] all_tasks.extend(TaskInfo.from_dict(item) for item in items) + if result_limit is not None and len(all_tasks) >= result_limit: + break if not data.get("has_more", False): break last_id = data.get("last_id") - if not last_id: - break + if not isinstance(last_id, str) or not last_id or last_id in cursors: + raise ValueError("Task list continuation cursor is missing or repeated") + cursors.add(last_id) params["after"] = last_id + if result_limit is not None: + params["limit"] = str(result_limit - len(all_tasks)) return all_tasks async def close(self) -> None: - """Close the underlying pipeline client.""" - await self._client.close() + """Close the underlying pipeline client and any credential created for this provider. + + The owned credential is kept until its close succeeds, so a failed close can be retried. + """ + try: + await self._client.close() + finally: + if self._owned_credential is not None: + await self._owned_credential.close() + self._owned_credential = None diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py index 73b271de039f..5bb090a079ea 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_context.py @@ -17,8 +17,6 @@ import asyncio # pylint: disable=do-not-import-asyncio from typing import Any, Callable, Generic, Literal, TypeVar -from azure.ai.agentserver.core._experimental import experimental - Input = TypeVar("Input") Output = TypeVar("Output") @@ -62,7 +60,6 @@ class _ExitForRecovery: __slots__ = () -@experimental class TaskContext(Generic[Input]): # pylint: disable=too-many-instance-attributes """The single parameter to a resilient task function. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py index a88112320a2d..44a5e9974645 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_decorator.py @@ -20,7 +20,9 @@ async def my_task(ctx: TaskContext[MyInput]) -> MyOutput: import asyncio # pylint: disable=do-not-import-asyncio import inspect import logging as _logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar from datetime import timedelta from typing import ( TYPE_CHECKING, @@ -34,10 +36,9 @@ async def my_task(ctx: TaskContext[MyInput]) -> MyOutput: import re -from azure.ai.agentserver.core._experimental import experimental - from ._client import TransportClassifiedError as _TransportClassifiedError from ._context import TaskContext +from ._exceptions import TaskManagerNotInitialized from ._exceptions_internal import _HostedConflict, _translate_hosted_conflict from ._retry import RetryPolicy from ._run import TaskRun @@ -144,6 +145,23 @@ def _validate_input_id(input_id: str) -> None: # Global registry of resilient task descriptors for recovery purposes. # Populated at import time when @task decorates a function. _REGISTERED_DESCRIPTORS: list[tuple[str, Callable[..., Any], "TaskOptions"]] = [] +# Recognize protocol identities even when their callbacks are not owned locally. +_HOST_BOUND_TASK_NAMES: set[str] = set() +_host_bound_registration: ContextVar[bool] = ContextVar("host_bound_task_registration", default=False) + + +@contextmanager +def _host_bound_task_registration() -> Iterator[None]: + """Require explicit owning-host registration for protocol task callbacks. + + :return: Registration scope for protocol-owned task definitions. + :rtype: Iterator[None] + """ + token = _host_bound_registration.set(True) + try: + yield + finally: + _host_bound_registration.reset(token) def _strip_reserved_tags(tags: dict[str, str]) -> dict[str, str]: @@ -417,6 +435,7 @@ class TaskOptions: # pylint: disable=too-many-instance-attributes "retry", "steerable", "_is_multi_turn", # — True when wrapped by @multi_turn_task + "_requires_host_registration", ) def __init__( @@ -438,6 +457,7 @@ def __init__( self.retry = retry self.steerable = steerable self._is_multi_turn = _is_multi_turn + self._requires_host_registration = _host_bound_registration.get() def __repr__(self) -> str: return ( @@ -447,7 +467,6 @@ def __repr__(self) -> str: ) -@experimental class Task(Generic[Input, Output]): """A decorated resilient task function. Not callable directly. @@ -475,14 +494,20 @@ def __init__( self._output_type = output_type self.name = opts.name # Register for recovery — manager picks these up at startup + if opts._requires_host_registration: + _HOST_BOUND_TASK_NAMES.add(opts.name) _REGISTERED_DESCRIPTORS.append((opts.name, fn, opts)) + if opts._requires_host_registration: + return # — if a TaskManager is already initialised (decorators # declared after startup, e.g. in tests), eagerly push into its # resume tables so _recover_stale_tasks / get_active_run can pick # up the multi-turn opts (_is_multi_turn). try: - from ._manager import _manager as _live_manager # pylint: disable=import-outside-toplevel - except ImportError: # pragma: no cover + from ._manager import get_task_manager # pylint: disable=import-outside-toplevel + + _live_manager = get_task_manager() + except (ImportError, TaskManagerNotInitialized): _live_manager = None # type: ignore[assignment] if _live_manager is not None: try: @@ -1025,12 +1050,19 @@ async def _lifecycle_start( # pylint: disable=too-many-locals # per Invariant 1. Operator-facing WARNING logs (in _manager.py # and _lease.py) are the only differentiator. try: - return await self._lifecycle_start_inner( - task_id=task_id, - input=input, - input_id=input_id, - if_last_input_id=if_last_input_id, - ) + from ._manager import get_task_manager, _task_manager_scope # pylint: disable=import-outside-toplevel + + manager = get_task_manager() + with _task_manager_scope(lambda: manager), manager._task_admission( # pylint: disable=protected-access + task_id=task_id + ): + async with manager._task_producer(task_id): # pylint: disable=protected-access + return await self._lifecycle_start_inner( + task_id=task_id, + input=input, + input_id=input_id, + if_last_input_id=if_last_input_id, + ) except _HostedConflict as exc: translated = _translate_hosted_conflict(exc, task_id=task_id) if translated is None: @@ -1351,7 +1383,6 @@ def task( ]: ... -@experimental def task( fn: Callable[..., Any] | None = None, *, @@ -1556,7 +1587,6 @@ def _validate_multi_turn_task_kwargs(**kwargs: Any) -> None: ) -@experimental class MultiTurnTask(Generic[Input, Output]): # pylint: disable=protected-access """A decorated multi-turn resilient task chain. @@ -1791,7 +1821,6 @@ def multi_turn_task( ]: ... -@experimental def multi_turn_task( fn: Callable[..., Any] | None = None, *, @@ -1804,28 +1833,28 @@ def multi_turn_task( ) -> Any: """Decorator producing a multi-turn resilient chain. - Multi-turn chains accept inputs across many turns against the same - ``task_id``. The handler's ``return X`` is the implicit-suspend - signal — there is no ``ctx.suspend``. The chain stays - alive across handler raises. - - :param fn: The handler to decorate (parameterized form only; the bare - ``@multi_turn_task`` form is rejected because it cannot supply a - ``name``). - :type fn: Callable[..., Any] | None - :keyword name: **Required** stable chain-identity anchor (no - function-derived default; omitting it raises ``ValueError``). - :keyword title: Static human-readable string. Callable-factory form is - not supported. - :keyword timeout: Per-turn cooperative timeout. Defaults to 1 day when - unset; 7 days is a hard ceiling (larger/negative rejected at - decoration). This is a per-turn cap, not a chain-lifetime bound. - :keyword retry: Default retry policy. - :keyword steerable: When True, ``start()`` against an in-flight chain - queues the new input instead of raising ``TaskConflictError``. - :return: A :class:`MultiTurnTask` instance (distinct public class from - :class:`Task`). - :rtype: Any + Multi-turn chains accept inputs across many turns against the same + ``task_id``. The handler's ``return X`` is the implicit-suspend + signal — there is no ``ctx.suspend``. The chain stays + alive across handler raises. + + :param fn: The handler to decorate (parameterized form only; the bare + ``@multi_turn_task`` form is rejected because it cannot supply a + ``name``). + :type fn: Callable[..., Any] | None + :keyword name: **Required** stable chain-identity anchor (no + function-derived default; omitting it raises ``ValueError``). + :keyword title: Static human-readable string. Callable-factory form is + not supported. + :keyword timeout: Per-turn cooperative timeout. Defaults to 1 day when + unset; 7 days is a hard ceiling (larger/negative rejected at + decoration). This is a per-turn cap, not a chain-lifetime bound. + :keyword retry: Default retry policy. + :keyword steerable: When True, ``start()`` against an in-flight chain + queues the new input instead of raising ``TaskConflictError``. + :return: A :class:`MultiTurnTask` instance (distinct public class from + :class:`Task`). + :rtype: Any """ # — reject unknown kwargs at decoration time _validate_multi_turn_task_kwargs(**_extra_kwargs) diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py index c90e84360b75..0fadb67b0793 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_enablement.py @@ -1,49 +1,35 @@ # --------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -"""Explicit opt-in switch for the resilient task subsystem. +"""Explicit task-runtime opt-in for the Invocations protocol. -The resilient ``TaskManager`` is constructed by ``AgentServerHost`` **only when -this switch is on**. It is the single source of truth for the durable task -subsystem: when the switch is off, no ``TaskManager`` is installed, so -``get_task_manager()`` raises -:class:`~azure.ai.agentserver.core.tasks.TaskManagerNotInitialized` and -``.run()`` / ``.start()`` cannot run a task. When on, the manager is -constructed and the startup crash-recovery scan (plus the periodic recovery -loop) runs. +The switch defaults to ``False`` and is evaluated before Invocations lifespan +startup. Declaring ``@task`` or ``@multi_turn_task`` does not enable execution. +The low-level ``AgentServerHost`` also uses this switch when no protocol has +provided its own activation requirements. -Recovery — and durable tasks as a whole — is therefore strictly opt-in. Merely -declaring a durable task (``@task`` / ``@multi_turn_task``) does **not** turn -the subsystem on; you must set this switch (directly, or via a protocol option -that maps to it, e.g. the responses ``resilient_background`` server option). +Responses does not read or change this switch. Its task runtime is enabled by +``ResponsesServerOptions.resilient_background`` or ``steerable_conversations``. +Protocol request scopes prevent one protocol's opt-in from enabling another. -The switch is process-global and defaults to ``False``. Set it before -``AgentServerHost`` lifespan startup (typically at import time):: +Set the switch before Invocations lifespan startup:: from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled set_resilient_tasks_enabled(True) """ -from azure.ai.agentserver.core._experimental import experimental - _RESILIENT_TASKS_ENABLED: bool = False -@experimental def set_resilient_tasks_enabled(value: bool = True) -> None: - """Opt in to (or clear) the resilient task subsystem process-wide. - - This gates whether ``AgentServerHost`` constructs the ``TaskManager`` at - lifespan startup. Setting it to ``True`` constructs the manager, runs the - startup crash-recovery scan, and starts the periodic recovery loop. When it - is ``False`` no manager is installed: ``get_task_manager()`` raises - :class:`~azure.ai.agentserver.core.tasks.TaskManagerNotInitialized` and a - durable task cannot run (callers such as the responses ``store=true`` path - swallow that and degrade to non-durable in-process execution). + """Enable or disable task execution for Invocations hosts. - Must be called before ``AgentServerHost`` lifespan startup (typically at - import time) to take effect. Defaults to enabling when called with no + Enabled hosts initialize a persistent task manager, perform startup recovery + and run the periodic recovery loop. Set this before lifespan startup. + Responses hosts use their own feature options independently, including on + cooperative multi-protocol hosts. This setting remains process-global for + Invocations configuration and defaults to enabling when called without an argument. :param value: ``True`` to enable the resilient task subsystem, ``False`` to @@ -54,13 +40,10 @@ def set_resilient_tasks_enabled(value: bool = True) -> None: _RESILIENT_TASKS_ENABLED = bool(value) -@experimental def resilient_tasks_enabled() -> bool: - """Return whether the resilient task subsystem is enabled. + """Return the Invocations task-runtime opt-in setting. - This is the authoritative gate: a ``False`` return means no ``TaskManager`` - is constructed and durable tasks / crash recovery are inactive (declaring a - task does NOT change this — the subsystem is opt-in). + This does not report whether a Responses host has enabled its own runtime. :return: ``True`` if :func:`set_resilient_tasks_enabled` turned it on. :rtype: bool diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py index c24416dfe0a9..fd56d961f5e8 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_exceptions.py @@ -12,10 +12,6 @@ import inspect from typing import Any -from azure.ai.agentserver.core._experimental import experimental - - -@experimental class TaskFailed(Exception): """Raised when a resilient task function raises an unhandled exception. @@ -49,7 +45,6 @@ def __init__(self, *args: Any, error: dict[str, Any] | None = None) -> None: ) -@experimental class TaskCancelled(Exception): """Raised when a resilient task is cancelled (: bare).""" @@ -75,7 +70,6 @@ def __init__(self, task_id: str | None = None) -> None: super().__init__(f"Task {task_id!r} not found") -@experimental class TaskConflictError(RuntimeError): """Raised when a task lifecycle conflict cannot be resolved. @@ -106,7 +100,6 @@ def __init__(self, *args: Any, current_status: str | None = None) -> None: ) -@experimental class TaskManagerNotInitialized(RuntimeError): """Raised when a resilient-task operation is attempted with no installed manager. @@ -129,7 +122,6 @@ def __init__(self, task_id: str, message: str | None = None) -> None: super().__init__(msg) -@experimental class SteeringQueueFull(RuntimeError): """Raised when the steering pending-input queue is at capacity (: bare).""" @@ -150,7 +142,6 @@ def __init__(self, task_id: str = "", message: str = "") -> None: super().__init__(message or "task precondition failed") -@experimental class LastInputIdPreconditionFailed(TaskPreconditionFailed): """Raised when ``Task.start``'s ``if_last_input_id`` precondition is not met. @@ -186,7 +177,6 @@ def __init__( # pylint: disable=super-init-not-called,unused-argument ) -@experimental class InputTooLarge(ValueError): """Raised when an input's serialized size exceeds the per-input cap (: bare).""" @@ -263,7 +253,6 @@ def __init__(self, task_id: str, current_count: int, max_count: int) -> None: from typing_extensions import Literal, TypedDict # type: ignore[assignment] -@experimental class TaskDeferred(Exception): """Raised when handler called ``ctx.exit_for_recovery``. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_local_provider.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_local_provider.py index a4f67123283a..f281ebc286bc 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_local_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_local_provider.py @@ -638,7 +638,7 @@ async def list( # pylint: disable=too-many-branches :paramtype has_error: bool | None :keyword lease_expired: Optional filter for tasks whose lease has expired. :paramtype lease_expired: bool | None - :keyword limit: Page size for pagination. + :keyword limit: Total result limit, clamped to 100; None returns all matching tasks. :paramtype limit: int | None :keyword after: Return tasks after this pagination cursor. :paramtype after: str | None @@ -648,15 +648,12 @@ async def list( # pylint: disable=too-many-branches :paramtype order: str | None :keyword omit_attachment_values: When True, omit attachment values from results. :paramtype omit_attachment_values: bool - :return: All matching tasks. + :return: Matching tasks, bounded by the explicit limit when supplied. :rtype: list[TaskInfo] """ if before is not None: _invalid_request("before is not supported for task list.") - page_size = 20 if limit is None else limit - if page_size <= 0: - _invalid_request("limit must be greater than 0.") - page_size = min(page_size, 100) + result_limit = _validation.normalize_list_limit(limit) sort_order = order or "desc" if sort_order not in {"asc", "desc"}: _invalid_request("order must be 'asc' or 'desc'.") @@ -698,7 +695,8 @@ async def list( # pylint: disable=too-many-branches break else: results = [] - results = results[:page_size] + if result_limit is not None: + results = results[:result_limit] if omit_attachment_values: for task in results: if task.attachments is not None: diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_manager.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_manager.py index 0600ec43b670..aeeaa14952fa 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_manager.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_manager.py @@ -11,13 +11,19 @@ from __future__ import annotations import asyncio # pylint: disable=do-not-import-asyncio +import inspect import logging import os import traceback -from collections.abc import Awaitable, Callable, Mapping -from typing import Any, TypeVar - -from .._config import AgentConfig +from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from functools import wraps +from itertools import takewhile +from typing import Any, Concatenate, ParamSpec, TypeVar + +from .._config import AgentConfig, resolve_state_subdir from .._request_context import ( FoundryAgentRequestContext, get_request_context, @@ -64,6 +70,9 @@ #: exposing it on ``@task``). This constant is the framework's choice. _DEFAULT_LEASE_SECONDS = 60 +#: Deferred provider closes for stopped managers, referenced until each finishes. +_PROVIDER_CLOSERS: set[asyncio.Task[None]] = set() + #: Pre-computed server version segment for source stamps. _SOURCE_SERVER_VERSION = _build_server_version("azure-ai-agentserver-core", _CORE_VERSION) @@ -283,27 +292,50 @@ def _lease_is_dead( return True +_scoped_task_manager: ContextVar[Callable[[], TaskManager | None] | None] = ContextVar( + "agentserver_task_manager", default=None +) + + +@contextmanager +def _task_manager_scope(get_manager: Callable[[], TaskManager | None]) -> Iterator[None]: + """Bind task access to the current host/protocol, including a disabled runtime. + + :param get_manager: Resolve the current runtime, or None for disabled access. + :type get_manager: Callable[[], TaskManager | None] + :return: The scoped task access context. + :rtype: Iterator[None] + """ + token = _scoped_task_manager.set(get_manager) + try: + yield + finally: + _scoped_task_manager.reset(token) + + def get_task_manager() -> TaskManager: - """Return the active TaskManager singleton. + """Return the current protocol's manager, or the installed standalone manager. :raises ~azure.ai.agentserver.core.tasks.TaskManagerNotInitialized: If no manager has been initialized. :return: The active manager. :rtype: TaskManager """ - if _manager is None: + scoped = _scoped_task_manager.get() + manager = scoped() if scoped is not None else _manager + if manager is None: from ._exceptions import ( # pylint: disable=import-outside-toplevel TaskManagerNotInitialized, ) raise TaskManagerNotInitialized( - "TaskManager not initialized: the resilient task subsystem is not enabled. " + "TaskManager not initialized: resilient tasks are disabled or session initialization is incomplete. " "Durable tasks and crash recovery are opt-in - call " - "set_resilient_tasks_enabled(True) before host startup (e.g. at import time), " - "or, for the responses protocol, construct the host with " - "ResponsesServerOptions(resilient_background=True)." # pylint: disable=implicit-str-concat + "set_resilient_tasks_enabled(True) for Invocations before host startup, " + "or enable resilient_background or steerable_conversations on " + "ResponsesServerOptions, run the host lifespan, and wait for session activation." ) - return _manager + return manager def set_task_manager(manager: TaskManager | None) -> None: @@ -318,6 +350,17 @@ def set_task_manager(manager: TaskManager | None) -> None: _manager = manager +def _clear_task_manager(manager: TaskManager | None) -> None: + """Clear only the compatibility singleton owned by this manager. + + :param manager: The host's task manager. + :type manager: TaskManager | None + :rtype: None + """ + if _manager is manager: + set_task_manager(None) + + class _ActiveTask: # pylint: disable=too-many-instance-attributes """In-memory tracking for a running task.""" @@ -393,6 +436,48 @@ def __init__( self._pending_input_count: int = 0 +class _TaskAdmissionClosed(RuntimeError): + """A stopped runtime cannot accept new task mutations.""" + + +_AdmissionP = ParamSpec("_AdmissionP") +_AdmissionR = TypeVar("_AdmissionR") + + +def _with_task_admission( + method: Callable[Concatenate[TaskManager, _AdmissionP], Awaitable[_AdmissionR]], +) -> Callable[Concatenate[TaskManager, _AdmissionP], Awaitable[_AdmissionR]]: + @wraps(method) + async def admitted(manager: TaskManager, *args: _AdmissionP.args, **kwargs: _AdmissionP.kwargs) -> _AdmissionR: + task_id = kwargs.get("task_id") + if task_id is None and args and isinstance(args[0], str): + task_id = args[0] + if task_id is None: + task_info = kwargs.get("task_info") + if task_info is None and args: + task_info = args[0] + task_id = getattr(task_info, "id", None) + with _task_manager_scope(lambda: manager), manager._task_admission( # pylint: disable=protected-access + task_id=task_id if isinstance(task_id, str) else None + ): + async with manager._task_producer( # pylint: disable=protected-access + task_id if isinstance(task_id, str) else None + ): + return await method(manager, *args, **kwargs) + + return admitted + + +@dataclass +class _TaskInitializationPhase: + completed: bool = False + completed_restore_id: str | None = None + + def complete(self, restore_id: str | None) -> None: + self.completed_restore_id = restore_id + self.completed = True + + class TaskManager: # pylint: disable=too-many-instance-attributes,protected-access,broad-exception-caught """Lifecycle orchestrator for resilient tasks. @@ -419,22 +504,42 @@ def __init__( shutdown_grace_seconds: float = 25.0, ) -> None: self._config = config - self._provider = provider or self._create_provider(config) + # A provider created here is owned and closed by this manager; an injected provider is not. + self._owns_provider = provider is None + self._provider = self._create_provider(config) if provider is None else provider + self._provider_closed = False + self._provider_closer: asyncio.Task[None] | None = None + self._provider_binding = self._provider_configuration() self._active_tasks: dict[str, _ActiveTask] = {} + self._owned_workers: set[asyncio.Task[None]] = set() self._resume_callbacks: dict[str, Callable[..., Any]] = {} self._resume_opts: dict[str, TaskOptions] = {} + self._disabled_recovery_names: set[str] = set() + self._host_bound_callback_names: set[str] = set() self._lease_owner = derive_lease_owner( config.agent_name or "unknown-agent", config.session_id or "local", ) self._instance_id = generate_instance_id() + self._materialization_restore_id: str | None = None self._shutdown_event = shutdown_event or asyncio.Event() + self._active_admissions: dict[asyncio.Task[Any], int] = {} + self._admitted_task_ids: dict[str, int] = {} + self._task_producers: dict[str, tuple[asyncio.Task[Any], asyncio.Event]] = {} + self._admissions_drained = asyncio.Event() + self._admissions_drained.set() + self._preserve_recoverable_tasks_on_shutdown = False + self._pending_shutdown_tasks: dict[int, _ActiveTask] = {} self._shutdown_grace_seconds = shutdown_grace_seconds self._active_generation_future: dict[str, asyncio.Future[Any]] = {} self._pending_steering_futures: dict[str, list[asyncio.Future[Any]]] = {} # Layer 2: periodic recovery scan task. Created # at startup() time; cancelled at shutdown(). self._periodic_recovery_task: asyncio.Task[None] | None = None + self._startup_lock = asyncio.Lock() + self._startup_phases = { + name: _TaskInitializationPhase() for name in ("construct", "register", "recovery", "periodic", "ready") + } # / C-WQ-1..3 — per-task write-queue # registry. A single asyncio.Lock per task_id serializes all # in-process PATCHes against that task so etag conflicts become @@ -452,6 +557,94 @@ def __init__( # on terminal exit. self._timeout_watchdogs: dict[str, asyncio.Task[None]] = {} + def _rehydrate_session_state(self, restore_id: str | None = None) -> None: + self._lease_owner = derive_lease_owner( + self._config.agent_name or "unknown-agent", + self._config.session_id or "local", + ) + if restore_id is not None and restore_id != self._materialization_restore_id: + if not self._has_live_task_work: + self._instance_id = generate_instance_id() + self._materialization_restore_id = restore_id + + @staticmethod + def _provider_configuration_for(config: AgentConfig) -> tuple[str, str | None]: + backend = os.environ.get("AGENTSERVER_TASKS_BACKEND", "").strip().lower() + backend = backend or ("hosted" if config.is_hosted else "local") + return backend, (config.project_endpoint if backend == "hosted" else str(resolve_state_subdir("tasks"))) + + def _provider_configuration(self) -> tuple[str, str | None]: + return self._provider_configuration_for(self._config) + + def _provider_configuration_matches(self, configuration: tuple[str, str | None] | None = None) -> bool: + return self._provider_binding == (self._provider_configuration() if configuration is None else configuration) + + @contextmanager + def _task_admission(self, *, task_id: str | None = None) -> Iterator[None]: + """Track producers without granting admission to inherited child tasks. + + :keyword task_id: Task whose registration must not be reclaimed by recovery. + :paramtype task_id: str or None + :return: Scope for one accepted submission. + :rtype: Iterator[None] + """ + caller = asyncio.current_task() + if caller is None: + raise RuntimeError("Task admission requires a running asyncio task") + depth = self._active_admissions.get(caller, 0) + if depth == 0 and self._shutdown_event.is_set(): + raise _TaskAdmissionClosed("Cannot submit tasks while the TaskManager is shutting down") + self._active_admissions[caller] = depth + 1 + if task_id is not None: + self._admitted_task_ids[task_id] = self._admitted_task_ids.get(task_id, 0) + 1 + self._admissions_drained.clear() + try: + yield + finally: + if task_id is not None: + remaining = self._admitted_task_ids[task_id] - 1 + if remaining: + self._admitted_task_ids[task_id] = remaining + else: + del self._admitted_task_ids[task_id] + if depth: + self._active_admissions[caller] = depth + else: + del self._active_admissions[caller] + if not self._active_admissions: + self._admissions_drained.set() + + @asynccontextmanager + async def _task_producer(self, task_id: str | None) -> AsyncIterator[None]: + """Serialize claim and registration, allowing the actual producer to re-enter. + + :param task_id: Task to coordinate, or None for non-task-specific operations. + :type task_id: str | None + :return: Scope for one task producer. + :rtype: AsyncIterator[None] + """ + if task_id is None: + yield + return + caller = asyncio.current_task() + if caller is None: + raise RuntimeError("Task production requires a running asyncio task") + while True: + producer = self._task_producers.get(task_id) + if producer is None: + released = asyncio.Event() + self._task_producers[task_id] = (caller, released) + try: + yield + finally: + del self._task_producers[task_id] + released.set() + return + if producer[0] is caller: + yield + return + await producer[1].wait() + @staticmethod def _build_source(fn_name: str) -> dict[str, str]: """Build the framework-owned source stamp for a task. @@ -527,17 +720,17 @@ def _create_provider(config: AgentConfig) -> TaskProvider: ) from exc logger.info("Hosted environment detected; using HostedTaskProvider") - return HostedTaskProvider( + credential = DefaultAzureCredential() + provider = HostedTaskProvider( project_endpoint=config.project_endpoint, - credential=DefaultAzureCredential(), + credential=credential, ) + provider._owned_credential = credential # pylint: disable=protected-access + return provider from ._local_provider import ( # pylint: disable=import-outside-toplevel LocalFileTaskProvider, ) - from .._config import ( # pylint: disable=import-outside-toplevel - resolve_state_subdir, - ) if backend_override == "local" and config.is_hosted: logger.info("AGENTSERVER_TASKS_BACKEND=local overrides hosted detection; using LocalFileTaskProvider") @@ -574,6 +767,16 @@ def register_resume_callback( self._resume_callbacks[fn_name] = fn if opts is not None: self._resume_opts[fn_name] = opts + if opts._requires_host_registration: + self._host_bound_callback_names.add(fn_name) + + def _disable_recovery_for(self, *fn_names: str) -> None: + """Exclude protocol task functions from this manager's recovery. + + :param fn_names: Persisted task function names disabled by their protocol. + :type fn_names: str + """ + self._disabled_recovery_names.update(fn_names) async def list_tasks( self, @@ -586,7 +789,8 @@ async def list_tasks( Uses server-side filtering (``agent_name``, ``session_id``, ``task_name`` tag, ``status``, ``source_type``) to return only - tasks created by this framework for the given function. + tasks created by this framework for the given function. Explicitly + requests complete enumeration, never a bounded first page. :keyword fn_name: The task function name (stable identity anchor). :paramtype fn_name: str @@ -608,6 +812,7 @@ async def list_tasks( status=status, tag={_TAG_TASK_NAME: fn_name}, source_type=_SOURCE_TYPE, + limit=None, ) except _HostedConflict as exc: translated = _translate_hosted_conflict(exc) @@ -728,40 +933,95 @@ async def _cancel_queued_steering_input( # pylint: disable=unused-argument if not future.done(): future.set_exception(TaskCancelled()) - async def startup(self) -> None: - """Initialize the manager and recover stale tasks. - - Called by ``AgentServerHost`` during lifespan startup. - """ - logger.info( - "TaskManager starting (owner=%s, instance=%s, hosted=%s)", - self._lease_owner, - self._instance_id, - self._config.is_hosted, + @property + def _has_live_task_work(self) -> bool: + return ( + bool(self._active_admissions) + or bool(self._active_tasks) + or any(worker is not self._periodic_recovery_task and not worker.done() for worker in self._owned_workers) + or any(not worker.done() for worker in self._timeout_watchdogs.values()) ) - # Pick up descriptors registered at import time (for recovery) - from ._decorator import ( # pylint: disable=import-outside-toplevel - _REGISTERED_DESCRIPTORS, + + @property + def _has_live_workers(self) -> bool: + return self._has_live_task_work or ( + self._periodic_recovery_task is not None and not self._periodic_recovery_task.done() ) - for fn_name, fn, opts in _REGISTERED_DESCRIPTORS: - self._resume_callbacks[fn_name] = fn - self._resume_opts[fn_name] = opts + @property + def _dispatch_closed(self) -> bool: + """Whether rollback cleanup forbids starting another handler turn or watchdog. - await self._recover_stale_tasks() + Ordinary shutdown keeps dispatching during its grace period, so handlers can + observe ``ctx.shutdown`` and defer through ``ctx.exit_for_recovery()``. - # Layer 2: start the periodic recovery task. - # Reads _PERIODIC_RECOVERY_INTERVAL_SECONDS at spawn time; - # tests monkey-patch the constant to drive the scan - # deterministically. - try: - loop = asyncio.get_running_loop() - self._periodic_recovery_task = loop.create_task(self._periodic_recovery_loop()) - except RuntimeError: - # No running loop (called from outside async context); skip - # — the layer-1 startup scan above still covered the - # initial reclaim pass. - pass + :return: True once restore rollback cleanup has begun. + :rtype: bool + """ + return self._shutdown_event.is_set() and self._preserve_recoverable_tasks_on_shutdown + + def _create_worker_task(self, coroutine: Coroutine[Any, Any, None]) -> asyncio.Task[None]: + worker = asyncio.create_task(coroutine) + self._owned_workers.add(worker) + worker.add_done_callback(self._owned_workers.discard) + return worker + + def _prepare(self, restore_id: str | None = None) -> None: + if self._shutdown_event.is_set(): + raise RuntimeError("Cannot initialize a stopped TaskManager") + self._startup_phases["construct"].complete(restore_id) + register = self._startup_phases["register"] + if not register.completed: + from ._decorator import ( # pylint: disable=import-outside-toplevel + _REGISTERED_DESCRIPTORS, + ) + + registered_callbacks = self._resume_callbacks.copy() + registered_opts = self._resume_opts.copy() + for fn_name, fn, opts in _REGISTERED_DESCRIPTORS: + if opts._requires_host_registration: + continue + self._resume_callbacks[fn_name] = fn + self._resume_opts[fn_name] = opts + self._resume_callbacks.update(registered_callbacks) + self._resume_opts.update(registered_opts) + register.complete(restore_id) + + @_with_task_admission + async def startup(self, *, restore_id: str | None = None) -> None: + """Initialize the manager and recover stale tasks. + + Called during ordinary session startup or after memory restoration. + + :keyword restore_id: Platform restore identity, or None for cold startup. + :paramtype restore_id: str or None + """ + async with self._startup_lock: + self._prepare(restore_id) + logger.info( + "TaskManager starting (owner=%s, instance=%s, hosted=%s, restore_id=%s)", + self._lease_owner, + self._instance_id, + self._config.is_hosted, + restore_id, + ) + recovery = self._startup_phases["recovery"] + if not recovery.completed or recovery.completed_restore_id != restore_id: + await self._recover_stale_tasks() + recovery.complete(restore_id) + + scanner = self._periodic_recovery_task + if scanner is not None and scanner.done(): + self._startup_phases["periodic"].completed = False + self._startup_phases["ready"].completed = False + self._periodic_recovery_task = None + if not scanner.cancelled(): + scanner.result() + logger.warning("Restarting stopped periodic task recovery scanner") + if self._periodic_recovery_task is None: + self._periodic_recovery_task = self._create_worker_task(self._periodic_recovery_loop()) + self._startup_phases["periodic"].complete(restore_id) + self._startup_phases["ready"].complete(restore_id) async def _periodic_recovery_loop(self) -> None: """Layer 2: periodic background recovery scan. @@ -790,13 +1050,170 @@ async def _periodic_recovery_loop(self) -> None: except Exception: # pylint: disable=broad-exception-caught logger.warning("Periodic recovery scan iteration failed", exc_info=True) - async def shutdown(self) -> None: + async def _cancel_workers(self, *, wait: bool, retained_workers: set[asyncio.Task[Any]] | None = None) -> None: + """Cancel owned execution, renewal, and watchdog tasks. + + :keyword wait: Whether to wait for the cancelled tasks to finish. + :paramtype wait: bool + :keyword retained_workers: Workers retained before shutdown was signalled. + :paramtype retained_workers: set[asyncio.Task[Any]] | None + """ + workers = set(retained_workers or ()) + workers.update(self._owned_workers) + for active in list(self._active_tasks.values()): + active.renewal_cancel.set() + if active.renewal_task and not active.renewal_task.done(): + workers.add(active.renewal_task) + if not active.execution_task.done(): + workers.add(active.execution_task) + for watchdog in self._timeout_watchdogs.values(): + if not watchdog.done(): + workers.add(watchdog) + for worker in workers: + if not worker.done(): + worker.cancel() + if not (wait and workers): + return + results = await asyncio.gather(*workers, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + logger.warning( + "Task worker failed during shutdown", + exc_info=(type(result), result, result.__traceback__), + ) + + async def _reconcile_steering_for_rollback(self, active_tasks: list[_ActiveTask]) -> None: + for active in active_tasks: + if active.opts is None or not active.opts.steerable: + continue + stored = await self._provider_get_tracked(active.task_id) + if stored is None or stored.status != "suspended": + continue + await self._try_drain_steering( + task_id=active.task_id, + ctx=active.context, + opts=active.opts, + result_future=active.result_future, + ) + + def _retain_shutdown_workers(self) -> set[asyncio.Task[Any]]: + self._pending_shutdown_tasks.update((id(active), active) for active in self._active_tasks.values()) + workers = {*self._owned_workers, *self._timeout_watchdogs.values()} + for active in self._pending_shutdown_tasks.values(): + workers.add(active.execution_task) + if active.renewal_task is not None: + workers.add(active.renewal_task) + return workers + + async def _begin_shutdown(self, preserve_recoverable_tasks: bool) -> set[asyncio.Task[Any]]: + if asyncio.current_task() in self._active_admissions: + raise RuntimeError("Cannot shut down a TaskManager from an in-flight submission") + retained = ( + self._preserve_tasks_for_shutdown() + if preserve_recoverable_tasks or self._preserve_recoverable_tasks_on_shutdown + else set() + ) + self._shutdown_event.set() + await self._admissions_drained.wait() + if self._preserve_recoverable_tasks_on_shutdown: + retained.update(self._retain_shutdown_workers()) + return retained + + def _defer_shutdown_futures(self, active_tasks: list[_ActiveTask]) -> None: + """Release old-lifetime waiters without removing their durable inputs. + + :param active_tasks: Retained accepted tasks whose workers have been joined. + :type active_tasks: list[_ActiveTask] + """ + from ._exceptions import TaskDeferred # pylint: disable=import-outside-toplevel + + futures = set(self._active_generation_future.values()) + futures.update(future for pending in self._pending_steering_futures.values() for future in pending) + futures.update(active.result_future for active in active_tasks) + for future in futures: + if not future.done(): + future.set_exception(TaskDeferred()) + self._pending_steering_futures.clear() + self._active_generation_future.clear() + + def _preserve_tasks_for_shutdown(self) -> set[asyncio.Task[Any]]: + """Retain accepted work before shutdown can wake or cancel its workers. + + :return: Retained worker tasks. + :rtype: set[asyncio.Task] + """ + self._preserve_recoverable_tasks_on_shutdown = True + return self._retain_shutdown_workers() + + def _signal_shutdown(self) -> None: + """Interrupt blocked recovery reads without waiting for serialized cleanup.""" + self._shutdown_event.set() + + async def _close_owned_provider(self) -> None: + """Close the provider this manager created; injected providers remain caller-owned. + + A failed or cancelled close leaves the provider owned so cleanup can retry it. + """ + if not self._owns_provider or self._provider_closed: + return + close = getattr(self._provider, "close", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + self._provider_closed = True + + async def _close_owned_provider_logged(self) -> None: + try: + await self._close_owned_provider() + except Exception: # pylint: disable=broad-exception-caught + logger.error("The owned task provider remains open after a failed close", exc_info=True) + + async def _close_owned_provider_after_workers(self) -> None: + current = asyncio.current_task() + while True: + workers = [worker for worker in self._owned_workers if worker is not current and not worker.done()] + if not workers: + break + await asyncio.gather(*workers, return_exceptions=True) + await self._close_owned_provider_logged() + + async def _close_owned_provider_after_shutdown(self) -> None: + """Close the owned provider after ordinary shutdown without joining its workers. + + Ordinary shutdown cancels workers without waiting for them, and their cleanup + may still write through the provider, so the close waits for them in the + background. A failed close is logged and leaves the provider open. + """ + if not self._owns_provider or self._provider_closed: + return + if self._provider_closer is not None and not self._provider_closer.done(): + return + if any(not worker.done() for worker in self._owned_workers): + closer = asyncio.create_task(self._close_owned_provider_after_workers()) + _PROVIDER_CLOSERS.add(closer) + closer.add_done_callback(_PROVIDER_CLOSERS.discard) + self._provider_closer = closer + return + await self._close_owned_provider_logged() + + async def shutdown(self, *, wait_for_workers: bool = False, preserve_recoverable_tasks: bool = False) -> None: """Signal shutdown on all active tasks and force-expire leases. Called by ``AgentServerHost`` during lifespan shutdown. + + :keyword wait_for_workers: Also wait for the cancelled execution, renewal, + and watchdog tasks to finish. Used before session configuration is + rolled back so handlers cannot observe the later binding. + :paramtype wait_for_workers: bool + :keyword preserve_recoverable_tasks: Stop workers before expiring leases + without treating rollback interruption as terminal cancellation. + Always waits for workers; used only for failed restore rollback. + :paramtype preserve_recoverable_tasks: bool """ logger.info("TaskManager shutting down") - self._shutdown_event.set() + retained_workers = await self._begin_shutdown(preserve_recoverable_tasks) + preserve_recoverable_tasks = self._preserve_recoverable_tasks_on_shutdown # Layer 2: stop the periodic recovery scan task. # Cancel cleanly so the shutdown event in its sleep wakes @@ -852,9 +1269,17 @@ async def shutdown(self) -> None: except asyncio.CancelledError: logger.info("TaskManager shutdown grace period interrupted") + remaining = list(self._active_tasks.values()) + if preserve_recoverable_tasks: + retained_workers.update(self._retain_shutdown_workers()) + remaining = list(self._pending_shutdown_tasks.values()) + # Finish old-binding writes before making the records reclaimable. + await self._cancel_workers(wait=True, retained_workers=retained_workers) + await self._reconcile_steering_for_rollback(remaining) + # Force-expire all leases. Tolerate cancellation here too. try: - for active in list(self._active_tasks.values()): + for active in remaining: try: await self._provider.update( active.task_id, @@ -875,15 +1300,17 @@ async def shutdown(self) -> None: # Cancel all renewal and execution tasks. Always do this so handlers # listening on the cancellation signal wake up and exit cleanly. - for active in self._active_tasks.values(): - active.renewal_cancel.set() - if active.renewal_task and not active.renewal_task.done(): - active.renewal_task.cancel() - if not active.execution_task.done(): - active.execution_task.cancel() + if not preserve_recoverable_tasks: + await self._cancel_workers(wait=wait_for_workers) + self._defer_shutdown_futures(remaining if preserve_recoverable_tasks else []) self._active_tasks.clear() - set_task_manager(None) + self._timeout_watchdogs.clear() + self._pending_shutdown_tasks.clear() + for phase in self._startup_phases.values(): + phase.completed = False + phase.completed_restore_id = None + _clear_task_manager(self) async def create_and_run( self, @@ -943,6 +1370,7 @@ async def create_and_run( ) return await handle.result() + @_with_task_admission async def create_and_start( # pylint: disable=too-many-locals,too-many-statements self, *, @@ -1131,7 +1559,7 @@ async def _steering_poll_cs() -> None: steering_poll_cb_cs = _steering_poll_cs - renewal_task = asyncio.create_task( + renewal_task = self._create_worker_task( lease_renewal_loop( self._provider, task_id, @@ -1154,7 +1582,7 @@ async def _steering_poll_cs() -> None: # Start execution terminate_event = asyncio.Event() terminate_reason_ref: list[str | None] = [None] - execution_task = asyncio.create_task( + execution_task = self._create_worker_task( self._execute_task( fn=fn, ctx=ctx, @@ -1184,6 +1612,8 @@ async def _steering_poll_cs() -> None: retry=retry, ) self._active_tasks[task_id] = active + if self._preserve_recoverable_tasks_on_shutdown: + self._pending_shutdown_tasks[id(active)] = active return TaskRun( task_id=task_id, @@ -1200,6 +1630,7 @@ async def _steering_poll_cs() -> None: # Resume happens via .start()/.run() against a suspended task; the lifecycle # state machine in _lifecycle_start_inner handles the resume transition. + @_with_task_admission async def get_active_run(self, task_id: str) -> TaskRun[Any] | None: # pylint: disable=too-many-return-statements """Return a TaskRun handle for an active (in-progress) task. @@ -1307,6 +1738,7 @@ async def get_active_run(self, task_id: str) -> TaskRun[Any] | None: # pylint: ) return None + @_with_task_admission async def _reclaim_one(self, task_info: TaskInfo) -> "TaskInfo | None": """: CAS-protected lease reclaim helper. @@ -1343,6 +1775,7 @@ async def _reclaim_one(self, task_info: TaskInfo) -> "TaskInfo | None": ), ) + @_with_task_admission async def _start_existing_task( # pylint: disable=too-many-locals,too-many-statements self, *, @@ -1539,7 +1972,7 @@ async def _steering_poll() -> None: steering_poll_cb = _steering_poll - renewal_task = asyncio.create_task( + renewal_task = self._create_worker_task( lease_renewal_loop( self._provider, task_id, @@ -1559,7 +1992,7 @@ async def _steering_poll() -> None: terminate_event = asyncio.Event() terminate_reason_ref: list[str | None] = [None] - execution_task = asyncio.create_task( + execution_task = self._create_worker_task( self._execute_task( fn=fn, ctx=ctx, @@ -1588,6 +2021,8 @@ async def _steering_poll() -> None: retry=retry, ) self._active_tasks[task_id] = active + if self._preserve_recoverable_tasks_on_shutdown: + self._pending_shutdown_tasks[id(active)] = active return TaskRun( task_id=task_id, @@ -1737,13 +2172,17 @@ async def _spawn_watchdog_for_turn( :paramtype ctx: TaskContext[Any] """ await self._cancel_watchdog_for_turn(task_id) + if self._dispatch_closed: + return # Spec 037 #8 — an unset timeout defaults to a 1-day per-turn budget # (was previously "no watchdog"). The value is validated + capped at # registration; here we only resolve the default. effective_timeout = _resolve_effective_timeout(opts.timeout) timeout_seconds = effective_timeout.total_seconds() remaining = await self._compute_remaining_for_watchdog(task_id, timeout_seconds, ctx) - self._timeout_watchdogs[task_id] = asyncio.create_task( + if self._dispatch_closed: + return + self._timeout_watchdogs[task_id] = self._create_worker_task( self._timeout_watchdog( timeout_seconds=timeout_seconds, cancel_event=ctx.cancel, @@ -1866,6 +2305,8 @@ async def _execute_task_loop( # pylint: disable=too-many-statements,too-many-br while True: ctx.retry_attempt = attempt try: + if self._dispatch_closed: + raise asyncio.CancelledError ambient_request_context = get_request_context() request_context_token = set_request_context( FoundryAgentRequestContext( @@ -2073,8 +2514,16 @@ async def _execute_task_loop( # pylint: disable=too-many-statements,too-many-br break # exit retry loop on success or suspend - except asyncio.CancelledError: + except (asyncio.CancelledError, _TaskAdmissionClosed): renewal_cancel.set() + if self._preserve_recoverable_tasks_on_shutdown: + from ._exceptions import ( # pylint: disable=import-outside-toplevel + TaskDeferred, + ) + + if not current_result_future.done(): + current_result_future.set_exception(TaskDeferred()) + break # asyncio.CancelledError is the cooperative-cancel path — # the handler chose to raise it (or the framework signalled # cancel via ctx.cancel and the handler did not catch). @@ -2362,6 +2811,17 @@ async def _try_drain_steering( # pylint: disable=too-many-branches,too-many-sta attachments_patch[_ref_key(next_entry)] = None next_input_raw = _read_input_value(next_entry, task_info.attachments) + # Admission and recovery must see the promoted turn as the current input. + input_mode, input_value = _resolve_input_storage( + next_input_raw, + threshold_bytes=_INPUT_THRESHOLD_BYTES, + key_for_attachment=_FUNCTION_INPUT_KEY, + task_id=task_id, + ) + payload["input"] = input_value + if input_mode == "attachment": + attachments_patch[_FUNCTION_INPUT_KEY] = next_input_raw + # Update steering state. (: previous_input is # no longer mirrored into _steering; only the active input + queue # state need to survive a crash mid-drain.) @@ -2399,7 +2859,7 @@ async def _try_drain_steering( # pylint: disable=too-many-branches,too-many-sta payload=payload, attachments=attachments_patch, if_match=etag, - **self._lease_ext_kwargs(task_id), + **self._lease_ext_kwargs(task_id, allow_inactive=self._preserve_recoverable_tasks_on_shutdown), ), ) # Spec 031 / FR-002 — the drain consumed the head; the steered @@ -2442,6 +2902,11 @@ async def _try_drain_steering( # pylint: disable=too-many-branches,too-many-sta _conflict_attempt=_conflict_attempt + 1, ) + if self._shutdown_event.is_set(): + # Persist the next input as recoverable without dispatching during shutdown. + await self._release_staged_lease(task_id) + return None + # Pop and bind the next pending steering future (if any) new_future: asyncio.Future[Any] | None = None steering_futures = self._pending_steering_futures.get(task_id, []) @@ -2521,12 +2986,62 @@ async def _try_drain_steering( # pylint: disable=too-many-branches,too-many-sta except Exception: # pylint: disable=broad-exception-caught logger.debug("Failed to clear drain_in_progress for %s", task_id) + if self._shutdown_event.is_set(): + await self._release_staged_lease(task_id) + return None + logger.info( "Steering drain: task %s drained next input", task_id, ) return new_ctx + async def _release_staged_lease(self, task_id: str) -> None: + """Make a steering input staged during ordinary shutdown reclaimable now. + + The drain PATCH renewed this instance's lease, and ordinary shutdown only + expires leases of tasks still running after its grace period. Rollback + cleanup instead expires its retained handles after joining their workers. + + After accepted producers drain, a stopping manager admits none, so while this + worker's handle stays registered this instance cannot start another execution + of the task. Renewal stops first so no heartbeat re-extends the lease. The + owner and instance checks and the tracked ETag fence other writers. + + :param task_id: The task whose next input was staged without dispatch. + :type task_id: str + """ + active = self._active_tasks.get(task_id) + if self._preserve_recoverable_tasks_on_shutdown or active is None: + return + await self._admissions_drained.wait() + renewal = active.renewal_task + if renewal is not None and renewal is not asyncio.current_task() and not renewal.done(): + renewal.cancel() + await asyncio.gather(renewal, return_exceptions=True) + try: + async with self._get_task_write_lock(task_id): + stored = await self._provider_get_tracked(task_id) + lease = getattr(stored, "lease", None) + owned = lease is not None and (lease.owner, lease.instance_id) == (self._lease_owner, self._instance_id) + if ( + not owned + or stored is None + or stored.status != "in_progress" + or self._active_tasks.get(task_id) is not active + ): + return + await self._provider_update_lock_held( + task_id, + TaskPatchRequest( + lease_owner=self._lease_owner, + lease_instance_id=self._instance_id, + lease_duration_seconds=0, + ), + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Failed to release staged steering lease for %s; it will expire", task_id, exc_info=True) + async def _handle_multi_turn_success( # pylint: disable=unused-argument self, *, @@ -2936,154 +3451,228 @@ async def _delete_if_pre_schema_legacy(self, task_info: TaskInfo) -> bool: logger.warning("Legacy task cleanup delete failed for %s", task_info.id, exc_info=True) return True - async def _recover_stale_tasks(self) -> None: - """Recover stale in-progress tasks from previous instances.""" - agent_name = self._config.agent_name or "default" - session_id = self._config.session_id or "local" + async def _list_tasks_for_recovery(self, agent_name: str, session_id: str) -> list[TaskInfo]: + """Stop a recovery read when shutdown needs its admission to drain. - try: - # / C-FLT-1 — scope the recovery scan to - # framework-owned tasks via source_type. Tasks created by - # other systems sharing the same (agent, session, - # lease_owner) triple MUST NOT be enumerated by the - # framework's reclaim path. - stale_tasks = await self._provider.list( + :param agent_name: Agent whose framework-owned tasks are queried. + :type agent_name: str + :param session_id: Native session scope. + :type session_id: str + :return: Recovery candidates, or no candidates when shutdown interrupts the read. + :rtype: list[~azure.ai.agentserver.core.tasks._models.TaskInfo] + """ + query = asyncio.create_task( + self._provider.list( agent_name=agent_name, session_id=session_id, status="in_progress", lease_owner=self._lease_owner, source_type=_SOURCE_TYPE, + limit=None, ) + ) + stopping = asyncio.create_task(self._shutdown_event.wait()) + try: + done, _ = await asyncio.wait((query, stopping), return_when=asyncio.FIRST_COMPLETED) + if query not in done: + logger.info("Stopping a recovery query before draining task admission") + return [] + return await query + finally: + for pending in (query, stopping): + if not pending.done(): + pending.cancel() + await asyncio.gather(query, stopping, return_exceptions=True) + + @_with_task_admission + async def _recover_stale_tasks(self) -> None: + """Recover stale in-progress tasks from previous instances.""" + agent_name = self._config.agent_name or "default" + session_id = self._config.session_id or "local" + + try: + stale_tasks = await self._list_tasks_for_recovery(agent_name, session_id) except Exception: # pylint: disable=broad-exception-caught logger.warning("Failed to query stale tasks for recovery", exc_info=True) return - for task_info in stale_tasks: - # Skip if we're already tracking this task - if task_info.id in self._active_tasks: + for task_info in takewhile(lambda _: not self._shutdown_event.is_set(), stale_tasks): + # Check owning-host authorization before any recovery writes. + if ( + task_info.id in self._active_tasks + or task_info.id in self._admitted_task_ids + or not self._recovery_is_authorized(self._find_resume_callback_name(task_info)) + ): continue + await self._recover_stale_task(task_info) - # (Spec 038) One-time legacy cleanup: delete pre-schema tasks - # (no payload.schema_version) instead of recovering them. - if await self._delete_if_pre_schema_legacy(task_info): - continue + @_with_task_admission + async def _recover_stale_task(self, task_info: TaskInfo) -> None: + """Keep one recovery claim and registration inside the same producer scope. - # — opportunistic orphan attachment cleanup. If a prior - # lifetime crashed between a steering-append attachment PATCH - # and the queue update (cannot happen in the happy path - # because Phase 4 makes them a single atomic PATCH, but - # defense-in-depth is cheap), delete any - # ``_steering_input_*`` attachment that no live ref in - # ``pending_inputs`` references. - try: - refreshed = await self._steering_cleanup_orphan_attachments(task_info) - if refreshed is not None: - # Cleanup wrote — adopt the post-cleanup record so the - # reclaim below carries the current etag (else reclaim - # 412s on the stale scan etag and recovery is skipped). - task_info = refreshed - except Exception: # pylint: disable=broad-exception-caught - logger.warning( - "Orphan attachment cleanup failed for %s", - task_info.id, - exc_info=True, - ) + :param task_info: Stale record selected by the recovery scan. + :type task_info: TaskInfo + """ + if ( + self._shutdown_event.is_set() + or task_info.id in self._active_tasks + or not self._recovery_is_authorized(self._find_resume_callback_name(task_info)) + ): + return - # Reclaim the lease with our new instance ID - try: - # / C-LSE-2 — both reclaim sites - # (inline AND cold-start/periodic) carry if_match. On - # 412, ABANDON per §25.3 — another process beat us; - # let the next scan re-evaluate. - # - # Route through _reclaim_one so the reclaim takes the - # per-task write lock AND refreshes the tracked etag from - # the post-reclaim record. Adopt that record as task_info - # so (a) the lease-renewal heartbeat's tracked etag - # matches the store — otherwise its first tick sends the - # stale pre-reclaim etag, 412s, and recovery is cancelled - # as "lost ownership" ~one lease-half-life in — and (b) - # _start_existing_task sees the post-reclaim lease - # generation/instance. - reclaimed_info = await self._reclaim_one(task_info) - if reclaimed_info is not None: - task_info = reclaimed_info + # (Spec 038) One-time legacy cleanup: delete pre-schema tasks + # (no payload.schema_version) instead of recovering them. + if await self._delete_if_pre_schema_legacy(task_info): + return + + # — opportunistic orphan attachment cleanup. If a prior + # lifetime crashed between a steering-append attachment PATCH + # and the queue update (cannot happen in the happy path + # because Phase 4 makes them a single atomic PATCH, but + # defense-in-depth is cheap), delete any + # ``_steering_input_*`` attachment that no live ref in + # ``pending_inputs`` references. + try: + refreshed = await self._steering_cleanup_orphan_attachments(task_info) + if refreshed is not None: + # Cleanup wrote — adopt the post-cleanup record so the + # reclaim below carries the current etag (else reclaim + # 412s on the stale scan etag and recovery is skipped). + task_info = refreshed + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "Orphan attachment cleanup failed for %s", + task_info.id, + exc_info=True, + ) + + # Reclaim the lease with our new instance ID + try: + # / C-LSE-2 — both reclaim sites + # (inline AND cold-start/periodic) carry if_match. On + # 412, ABANDON per §25.3 — another process beat us; + # let the next scan re-evaluate. + # + # Route through _reclaim_one so the reclaim takes the + # per-task write lock AND refreshes the tracked etag from + # the post-reclaim record. Adopt that record as task_info + # so (a) the lease-renewal heartbeat's tracked etag + # matches the store — otherwise its first tick sends the + # stale pre-reclaim etag, 412s, and recovery is cancelled + # as "lost ownership" ~one lease-half-life in — and (b) + # _start_existing_task sees the post-reclaim lease + # generation/instance. + reclaimed_info = await self._reclaim_one(task_info) + if reclaimed_info is not None: + task_info = reclaimed_info + logger.info( + "Reclaimed stale task %s (generation will increment)", + task_info.id, + ) + except _HostedConflict as exc: + translated = _translate_hosted_conflict(exc, task_id=task_info.id) + if translated is None or getattr(translated, "current_status", None) == "in_progress": logger.info( - "Reclaimed stale task %s (generation will increment)", + "Reclaim conflict for task %s — another process beat us; letting next scan re-evaluate.", task_info.id, ) - except _HostedConflict as exc: - translated = _translate_hosted_conflict(exc, task_id=task_info.id) - if translated is None or getattr(translated, "current_status", None) == "in_progress": - logger.info( - "Reclaim conflict for task %s — another process beat us; letting next scan re-evaluate.", - task_info.id, - ) - continue + else: logger.warning("Failed to reclaim task %s", task_info.id, exc_info=True) - continue - except (EtagConflict, ValueError) as exc: - # 412 ABANDON for reclaim per §25.3. - if isinstance(exc, ValueError) and "etag" not in str(exc).lower(): - logger.warning("Failed to reclaim task %s", task_info.id, exc_info=True) - continue - logger.info( - "Reclaim 412 for task %s — another process beat us; " - "letting next scan re-evaluate (/ §25.3 ABANDON).", - task_info.id, - ) - continue - except Exception: # pylint: disable=broad-exception-caught + return + except (EtagConflict, ValueError) as exc: + # 412 ABANDON for reclaim per §25.3. + if isinstance(exc, ValueError) and "etag" not in str(exc).lower(): logger.warning("Failed to reclaim task %s", task_info.id, exc_info=True) - continue + return + logger.info( + "Reclaim 412 for task %s — another process beat us; " + "letting next scan re-evaluate (/ §25.3 ABANDON).", + task_info.id, + ) + return + except Exception: # pylint: disable=broad-exception-caught + logger.warning("Failed to reclaim task %s", task_info.id, exc_info=True) + return - # Find resume callback and dispatch - fn = self._find_resume_callback(task_info) - if fn is not None: - try: - # Look up stored opts for resumed-task configuration. - fn_name = (task_info.source or {}).get("name", "") - opts = self._resume_opts.get(fn_name) - await self._start_existing_task( - fn=fn, - fn_name=task_info.agent_name, - task_info=task_info, - entry_mode="recovered", - opts=opts, - ) - logger.info("Recovered task %s is now active", task_info.id) - except Exception: # pylint: disable=broad-exception-caught - logger.warning( - "Failed to resume recovered task %s", - task_info.id, - exc_info=True, - ) + # Find resume callback and dispatch + fn = self._find_resume_callback(task_info) + if fn is not None: + try: + # Look up stored opts for resumed-task configuration. + fn_name = (task_info.source or {}).get("name", "") + opts = self._resume_opts.get(fn_name) + await self._start_existing_task( + fn=fn, + fn_name=task_info.agent_name, + task_info=task_info, + entry_mode="recovered", + opts=opts, + ) + logger.info("Recovered task %s is now active", task_info.id) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "Failed to resume recovered task %s", + task_info.id, + exc_info=True, + ) def _find_resume_callback(self, task_info: TaskInfo) -> Callable[..., Any] | None: """Find a registered resume callback for a task. - Matches by ``source.name`` (auto-stamped function name) first, - then falls back to title prefix match or single-callback default. + An explicit ``source.name`` must match a registered callback. + Title-prefix and single-callback fallback apply only to legacy + records without a source identity. :param task_info: The task record to match. :type task_info: TaskInfo :return: A matching resume callback, or None. :rtype: Callable[..., Any] | None """ - # Preferred: match by source.name (framework auto-stamped fn name) + name = self._find_resume_callback_name(task_info) + if name is None or not self._recovery_is_authorized(name): + return None + return self._resume_callbacks.get(name) + + def _recovery_is_authorized(self, name: str | None) -> bool: + """Check protocol opt-in and explicit ownership of internal callbacks. + + :param name: Persisted or legacy-resolved callback name. + :type name: str | None + :return: Whether recovery may touch and dispatch this task. + :rtype: bool + """ + from ._decorator import _HOST_BOUND_TASK_NAMES # pylint: disable=import-outside-toplevel + + return ( + name in self._resume_callbacks + and name not in self._disabled_recovery_names + and (name not in _HOST_BOUND_TASK_NAMES or name in self._host_bound_callback_names) + ) + + def _find_resume_callback_name(self, task_info: TaskInfo) -> str | None: + """Read explicit identity or resolve an identity-free legacy record. + + :param task_info: The persisted task record. + :type task_info: TaskInfo + :return: Persisted function identity or a legacy callback name. + :rtype: str | None + """ + # Never substitute another callback for an explicit persisted identity. if task_info.source and "name" in task_info.source: source_name = task_info.source["name"] - if source_name in self._resume_callbacks: - return self._resume_callbacks[source_name] + if not isinstance(source_name, str): + logger.warning("Skipping task %s with invalid source.name", task_info.id) + return None + return source_name # Fallback: title prefix match - for name, fn in self._resume_callbacks.items(): + for name in self._resume_callbacks: if task_info.title and task_info.title.startswith(name): - return fn + return name # Last resort: single registered callback if len(self._resume_callbacks) == 1: - return next(iter(self._resume_callbacks.values())) + return next(iter(self._resume_callbacks)) return None # --------------------------------------------------------------- # @@ -3465,7 +4054,7 @@ async def _terminal_412_decide( # Branch (c): retry. return "retry" - def _lease_ext_kwargs(self, task_id: str) -> dict[str, Any]: + def _lease_ext_kwargs(self, task_id: str, *, allow_inactive: bool = False) -> dict[str, Any]: """Return lease-ownership kwargs for piggyback on a payload PATCH. Every framework-issued PATCH that mutates payload (metadata @@ -3486,11 +4075,14 @@ def _lease_ext_kwargs(self, task_id: str) -> dict[str, Any]: :param task_id: The task identifier. :type task_id: str + :keyword allow_inactive: Include the manager's lease identity for fenced + rollback reconciliation after the execution has stopped. + :paramtype allow_inactive: bool :return: kwargs for ``TaskPatchRequest`` carrying lease params, - or ``{}`` if this task is not active locally. + or ``{}`` if inactive tasks were not requested and this task is not active locally. :rtype: dict[str, Any] """ - if self._active_tasks.get(task_id) is None: + if self._active_tasks.get(task_id) is None and not allow_inactive: return {} return { "lease_owner": self._lease_owner, diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_provider.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_provider.py index dcb33dc778c7..1ac0150871c7 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_provider.py @@ -94,6 +94,12 @@ async def list( ) -> list[TaskInfo]: """List tasks with filters. + Without a limit, enumerate all matching records across every page. + Providers own continuation cursors, which may be opaque service tokens. + An explicit positive limit bounds the total result count, clamped to + 100, not the number of results per service page. Never return a partial + scan as successful complete enumeration when a continuation fails. + :keyword agent_name: Filter by agent name. :paramtype agent_name: str :keyword session_id: Filter by session ID. @@ -110,7 +116,7 @@ async def list( :paramtype has_error: bool | None :keyword lease_expired: Filter by whether the task's lease has expired. :paramtype lease_expired: bool | None - :keyword limit: Maximum number of records to return. + :keyword limit: Total result limit, clamped to 100; None requires complete enumeration. :paramtype limit: int | None :keyword after: Return records after this pagination cursor. :paramtype after: str | None diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py index f388825cbd67..0ff49d1e58c4 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_retry.py @@ -13,8 +13,6 @@ import random from datetime import timedelta -from azure.ai.agentserver.core._experimental import experimental - #: Spec 037 #11 — hard caps on the retry knobs. A misconfiguration must not # let a task turn retry unboundedly, so values outside these ranges are # rejected at construction (fail-fast, not clamped). A developer may still @@ -25,7 +23,6 @@ _MAX_RETRY_DELAY = timedelta(hours=1) -@experimental class RetryPolicy: """Retry configuration for resilient tasks. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py index e553ca0070be..da88c7902fe5 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_run.py @@ -22,8 +22,6 @@ import asyncio # pylint: disable=do-not-import-asyncio from typing import Any, Generic, TypeVar -from azure.ai.agentserver.core._experimental import experimental - Output = TypeVar("Output") @@ -41,7 +39,6 @@ def _unwrap_result(res: Any) -> Any: return res -@experimental class TaskRun(Generic[Output]): # pylint: disable=too-many-instance-attributes """Handle to a running or completed resilient task. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_validation.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_validation.py index 70c621f3801a..cfef42619bd1 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_validation.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/tasks/_validation.py @@ -90,6 +90,21 @@ def _reject(code: str, message: str) -> NoReturn: raise _HostedConflict(_code=code, status_code=400, message=message) +def normalize_list_limit(limit: int | None) -> int | None: + """Validate a total result cap while preserving complete enumeration. + + :param limit: Requested result cap, or ``None`` for all matches. + :type limit: int | None + :return: The positive cap clamped to 100, or ``None``. + :rtype: int | None + """ + if limit is None: + return None + if limit <= 0: + _reject("invalid_request", "limit must be greater than 0.") + return min(limit, 100) + + def _canonical_json_bytes(value: Any) -> int: """Return the UTF-8 byte length of ``value`` serialized as canonical JSON. diff --git a/sdk/agentserver/azure-ai-agentserver-core/docs/memory-snapshot-task-integration.md b/sdk/agentserver/azure-ai-agentserver-core/docs/memory-snapshot-task-integration.md new file mode 100644 index 000000000000..289bfec05ed2 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/docs/memory-snapshot-task-integration.md @@ -0,0 +1,819 @@ +# Design: resilient tasks with memory snapshots + +**Status:** draft implementation and design review. + +This design builds on the snapshot lifecycle hooks in +[Python SDK PR #49217](https://github.com/Azure/azure-sdk-for-python/pull/49217). +It separates process-side task definitions from session-bound TaskManager +construction and startup. Applications submit startup work from the existing +`after_restore_handler`: the requested manager is prepared before the handler, +and automatic recovery starts after the handler finishes rebuilding dependencies. +No new public callback or capture-time launch registry is required. + +## Problem + +A golden memory snapshot is an initialized agent-version template. It is not +a checkpoint of the most recent session. Process memory and filesystem state +at capture are retained, but subsequent session mutations and external service +state are not incorporated into that template. + +The current host constructs TaskManager during ASGI startup. That same startup +can run during golden capture before a real session has been assigned. +TaskManager selects a provider, derives lease-owner identity, creates worker +identity, and starts recovery. Capturing it at that point couples a reusable +process template to session execution. + +Updating environment variables and shared configuration after restoration does +not, by itself, reconstruct all session-bound objects or safely reset workers. +Instead, this change retains task definitions during process preparation and +constructs the requested runtime during real-session activation. + +## Scope + +Included: + +- Python Core task runtime activation through ordinary cold startup or the + existing `after-restore` hook. +- Process-side task definitions and existing Responses task registration. +- Calling decorated task methods from the existing application restore handler. +- Immediate recovery, initialization retry checkpoints, runtime ownership, + and shutdown/rollback handling. +- A distinction between intentional resilience opt-out and an enabled runtime + that has not yet initialized. + +Not included: + +- New application session-start callbacks or captured startup launch intentions. +- A general framework for rebuilding every protocol/client object. +- Changes to hosted task storage, stream replay storage, lease timing, + task execution, or recovery filtering/fencing policy. +- A guarantee that arbitrary sockets, cached credentials, or application + dependencies are snapshot-safe. +- Platform recovery implementation, .NET support, or public release rollout. + +## Two independent controls + +Platform memory-snapshot opt-in is a property of the hosted agent definition. +[REST-spec PR #46759](https://github.com/Azure/azure-rest-api-specs/pull/46759) +proposes: + +```json +{ + "definition": { + "kind": "hosted", + "enable_memory_snapshot": true + } +} +``` + +That field requests platform capture/materialization. It is independent of the +SDK startup variable below; setting the variable alone does not enable +platform snapshots. The REST proposal is subscription/region gated, defaults +to false, and introduces no extra feature header. + +`FOUNDRY_MEMORY_SNAPSHOT_ENABLED` selects the SDK's ordinary startup trigger: + +| Value | Enabled task subsystem at ordinary startup | +|---|---| +| Absent | Same as `false`: activate during ordinary cold/local initialization | +| `true` | Defer construction/startup; activate through after-restore | +| `false` | Activate during ordinary cold/local session initialization | + +Task runtime requirements are protocol-specific, matching ordinary startup: + +| Host / protocol | Requests task execution | +|---|---| +| Invocations, including HTTP/WebSocket/Voice | `set_resilient_tasks_enabled(True)` | +| Responses | `resilient_background=True` or `steerable_conversations=True` | +| Standalone Core without protocol requirements | The core/Invocations switch | +| Cooperative multi-protocol host | Any enabled protocol requests one owned manager | + +The Invocations switch does not enable or disable Responses. Responses options +do not mutate that switch. Declaring a task alone never requests the runtime. +The snapshot variable only chooses when an already-requested runtime starts. + +| Host requests task runtime | Environment value | Ordinary startup | After-restore | +|---|---|---|---| +| No | absent, `false`, or `true` | No task subsystem | No task subsystem | +| Yes | absent or `false` | Activate immediately | Activate/retain the same session runtime | +| Yes | `true` | Defer activation | Prepare before the application callback; recover after it | + +Request access remains protocol-scoped even when a mixed host owns a manager. +An opted-out Invocations route cannot borrow the Responses runtime, and an +opted-out Responses route cannot borrow the Invocations runtime. The generic +application restore callback uses the owning host's scope and may submit tasks +when any of that host's protocols requests the runtime. + +Present values must be case-insensitive `true` or `false`; surrounding +whitespace is accepted. Empty/malformed values fail explicitly. Missing is +treated as false. The flag chooses an initialization trigger, not permission +to execute or renew leases after activation. + +After-restore is itself the explicit activation signal. Activation does not +require a new flag override or changing the flag to false. + +### Compatibility and rollout requirement + +Non-snapshot/local launchers need no new environment variable: absent preserves +ordinary startup. Every temporary capture producer must supply `true`, including +direct capture and queued version/Managed Harness package warm templates. +The captured value survives restoration; no restore override is required, and +the hook explicitly activates tasks while the value remains `true`. + +An unmarked capture would start enabled tasks immediately and could capture +session-bound runtime state. The before-snapshot callback is not a substitute +for correct startup signaling. Coordinate service and SDK rollout: the earlier +prototype treated absence as deferred, so removing its cold-boot `false` wiring +requires this SDK update too. Prior registry-prototype hosted results below do +not qualify the revised signal contract. + +## Lifecycle + +### Process preparation and capture + +```text +imports and session-independent warmup + -> construct protocol routes/orchestrators + -> register task definitions and options + -> preserve resilience intent + -> capture environment explicitly sets the snapshot variable to true + -> ordinary startup defers TaskManager + -> before-snapshot + -> capture warmed process +``` + +The existing `_REGISTERED_DESCRIPTORS` contains definitions, not submitted +task records or launch intentions. Manager construction is deferred, but +definition registration is not. Responses can register its one-shot and +multi-turn task functions before a manager exists. + +Capture process readiness does not mean that session-bound tasks are active. +No application task should acquire a lease or execute in the capture sandbox. + +### Ordinary cold startup + +```text +absent or false + at least one owning-protocol runtime requirement + -> construct manager with current session configuration + -> install registered callbacks + -> existing initial recovery + -> one periodic recovery scanner + -> admit normal requests +``` + +Local development uses this path without platform snapshots. +Sequential cold-start lifespans on the same host reset the task shutdown gate +and create a fresh enabled runtime. Snapshot-mode startup still waits for +session activation; disabled tasks remain disabled. + +### Snapshot materialization + +```text +apply real-session environment/configuration + -> protected SDK restore extension + -> construct/bind requested TaskManager + -> load existing registered definitions + -> optional application after_restore_handler: + rebuild application dependencies + submit decorated startup tasks using normal .start()/.run() APIs + -> initial recovery: detect, reclaim, dispatch immediately + -> ensure one periodic scanner + -> commit successful restore + -> platform admits new requests +``` + +Submitted and recovered tasks may execute and renew leases while after-restore is still +running. There is no barrier waiting for the hook response or platform +acknowledgment, and the hook does not wait for LRA completion. + +The manager has a provider, real session/worker identity and registered task +definitions when the application handler runs. It has not yet started its +automatic recovery scan or periodic scanner. Applications rebuild dependencies +before submitting their own tasks. Recovery waits until the handler returns, +so previously persisted work cannot race ahead of that preparation. + +This ordering applies to a newly prepared runtime. An already-active cold or +retained runtime/scanner is not paused by this change; live idle-snapshot +quiescence is a separate lifecycle requirement. + +## Initialization phases and retry + +TaskManager retains private phase state: + +| Phase | Completion means | +|---|---| +| Construct/bind | A manager exists for the bound session and agent, before the app handler | +| Register | Captured definitions/options are installed; task submission is available | +| Recovery | The application handler returned; best-effort initial scan completed | +| Periodic | One scanner is present and running | +| Ready | Required initialization completed | + +A completed phase stores the incoming `restore_id`, and is stamped only after +accepted completion. Cold startup uses private instance-once semantics without +inventing a platform restore event. + +- The hook lifecycle lock and completed-restore guard serialize/deduplicate + incoming hooks. +- TaskManager's startup lock independently prevents concurrent startup from + duplicating workers. +- A same-ID retry reuses valid completed phases and retries unfinished steps. +- A completed restore ID does not replay the application handler. If host + shutdown disposed the runtime, the same payload re-establishes it before + acknowledging success; delayed older IDs use the latest committed binding. +- Recovery is reconsidered for a different restore ID without creating an + additional valid manager/scanner for the same binding. +- A stopped scanner is not silently counted as readiness. A scanner exception + is surfaced; invalid periodic/readiness markers are cleared. +- Shutdown invalidates phase state. A stopped manager cannot restart. +- A host refuses a manager owned by another host or a conflicting session + binding rather than replacing it silently. +- Host/protocol context scopes resolve the owning runtime dynamically, so a + deferred manager becomes visible after restore without leaking a different + host's runtime. Shutdown clears only its own compatibility singleton. +- Protocol recovery callbacks/options are explicitly registered before + preparation/recovery; host bindings win over process-wide descriptor order. + Disabled or unregistered protocol functions are not reclaimed. +- An inactive manager whose construction-time provider configuration no longer + matches hydrated settings is disposed and rebuilt. Provider changes while + workers are live fail explicitly instead of sending work to a stale endpoint. + The candidate provider signature is checked before publishing restored + configuration or rotating lease identity. Rejection rolls back temporary + environment overrides without changing the existing workers' lease identity. +- Runtime identity includes agent name as well as session ID/GUID. Live + agent-name and session-GUID changes are rejected before environment, + configuration, or lease-instance mutation; incompatible inactive runtimes + are rebuilt. Inactive means the manager owns no workers, including the + periodic recovery scanner: a prepared runtime that has not started, or one + stopped by rollback cleanup. A started runtime keeps its scanner bound to its + provider and lease owner, so it rejects these changes instead of rebuilding. +- Local provider comparison uses `resolve_state_subdir("tasks")`, including + the default home directory when the root variable is unset or empty. +- A new restore ID with compatible session/provider identity preserves the lease + instance while task workers or accepted submissions are still owned. Existing + renewal workers and later task writes keep the same identity; the restore ID + still advances. With no owned task work, the lease instance rotates as before, + even if the periodic recovery scanner remains alive. Golden capture creates + no manager, so separate session clones still initialize independent instances. + Recovery also excludes task IDs whose accepted submission or resume is still + registering its active handle, avoiding duplicate dispatch during that gap. + This includes the outer start/run claim and positional active-run lookup, + before either path reaches active-task registration. + +This is not a general retained-memory reconciliation framework. Changes to +session-incarnation binding require explicit lifecycle treatment rather than +blind reuse or duplicate initialization. + +## Failure, cancellation, and runtime ownership + +The host owns the current manager, not a lifespan-local variable captured before +restore. A manager created after lifespan entry must still be stopped when that +host exits. + +Protocol shutdown signals handlers before manager draining. Manager cleanup +cancels owned execution, renewal, and watchdog tasks, invalidates +initialization state, and clears only its own singleton. Before the host rolls +back session configuration, it asks cleanup to also wait for those tasks to +finish, so cancelled handlers complete under the binding they started with. +This rollback-only cleanup preserves unfinished task records and saved inputs. +It stops workers before expiring their leases, allowing a restore retry to +recover the original task rather than treating interruption as user cancellation. +Manager shutdown first closes task mutation admission and waits for already +accepted submissions to settle before taking the final worker/lease snapshot. +This includes submissions whose provider create/claim has not yet produced an +active entry. Same-task producers are coordinated across the complete claim and +worker-registration operation, so inline lookup cannot claim and launch a +second worker while recovery awaits a committed provider response. Nested calls +from the same actual async producer can re-enter; other producers wait without +holding the provider write lock. Cancelled waiters release their admission. +Late accepted handles remain owned even if their workers finish +before the snapshot. New child submissions from cancellation/finally code are +rejected, not launched; rejection during rollback preserves the parent for +recovery rather than turning cleanup into terminal task failure. +Admission is tied to the actual producer task, not inherited context-variable +permission. Awaiting a submitted run's result does not hold admission open. +Recovery LIST reads observe shutdown and cancel/join their read before admission +drains, avoiding a scanner-cancellation deadlock. Host shutdown signals the +already-created manager before waiting for the serialized manager lock, so an +initial recovery read blocked inside after-restore activation is interrupted +instead of holding cleanup behind that lock. Ordinary host teardown stays +non-joining, except while a restore rollback is pending: then it preserves +unfinished task records and inputs and waits for cancelled workers so their +cleanup cannot see configuration reverted by the later rollback. Preservation +and worker retention are established before protocol shutdown callbacks or the +early manager signal can wake cooperative workers, allowing restore retry to +recover the original accepted work. Accepted provider creates, +claims and other mutations still settle before worker/lease cleanup. +The gate applies only once TaskManager shutdown begins, not as a new HTTP +admission policy or a capture-time launch queue. Explicit cancellation of an +individual task keeps its normal behavior. +A manager closes the task provider it created, and the credential created for +that provider, only after its workers stop. Rollback cleanup and inactive-runtime +replacement join workers and close before discarding the stopped manager. A +failed or cancelled close keeps that manager, and any pending rollback, for a +cleanup retry; no replacement is published until the close succeeds, and a +retained stopped manager is never reused. Ordinary shutdown stays non-joining: +it unpublishes the manager and closes after cancelled workers finish, logging +a failed close that leaves the provider open. Injected providers and +caller-supplied credentials remain caller-owned. +The manager owns execution, renewal, scanner and watchdog workers until they +finish, independently of active task records. Rollback joins those workers, +including renewals whose handlers completed before cleanup began. +No new steering turn executes during cleanup. If the current turn finishes +normally, the next queued input is durably staged as `in_progress` for recovery, +without invoking its handler; the next activation resumes that input. +During ordinary shutdown, once accepted producers drain, the worker stops that +task's renewal and releases the lease that staging renewed while its handle is +still registered, so another instance can recover the input without waiting +for expiry. Owner and instance checks and the tracked ETag fence other writers. +Rollback cleanup checks shutdown immediately before handler dispatch, including +after watchdog setup, so accepted, retried and recovered work is retained without +starting a handler. Ordinary shutdown keeps dispatching during its grace period, +so handlers can observe `ctx.shutdown` and defer with `ctx.exit_for_recovery()`; +explicit and post-grace cancellation keep their existing behavior. +Before disposing a manager, +unfinished queued and promoted steering futures receive `TaskDeferred`; their +in-memory registrations are cleared, not their persisted inputs. Existing +completed or cancelled futures retain their outcomes. +Preserving shutdown also settles unfinished result futures from its retained +accepted handles after joining workers. This includes cancellation during the +initial watchdog/provider read, before the execution loop or handler starts. +The original task record and input remain available for replacement recovery. +After workers stop, rollback reconciles any suspended steering chain whose +next-input transition was interrupted. Reconciliation uses the same fenced +drain path with an explicit lease claim before that lease is expired for retry. +If reconciliation fails, cleanup propagates the error rather than rolling +configuration back while queued work is stranded. +The stopped manager retains its reconciliation handles until cleanup succeeds. +A later hook first retries that cleanup under the original session binding, +before rehydrating configuration or invoking restore callbacks. Only then is +the stopped manager disposed and a replacement activated. Repeated cleanup +failures remain failures; an already committed input promotion is not repeated. +The host also retains the original environment/configuration rollback snapshots +until cleanup and rollback finish. It restores those values before computing a +retry's environment baseline, so omitted overrides cannot retain a failed value +and repeated overrides cannot replace the captured baseline with that value. +Ordinary host shutdown also finishes any pending rollback preservation rather +than discarding its queued work. +A newly restored manager records +its restore ID before recovery so a same-ID retry cannot rotate lease identity +under retained live workers. + +Completed inactive phase resources can survive a failed restore attempt only +when their session, agent and provider binding matches the rollback target. +An incompatible prepared manager is stopped and unpublished before +configuration rollback even if no worker started, so later submissions cannot +use a provider left behind by the failed attempt. Compatible phase resources +can be reused after the same session is rebound. However, rollback cannot revert +shared configuration while recovered workers continue using an incompatible +binding. Compatibility includes agent identity and the provider backend/location +captured before restore, not just session ID/GUID. A changed agent name, +endpoint, backend or local state +root requires worker quiescence even when session identity matches. Records +remain recoverable in their original provider; rollback does not migrate work +between projects or storage roots. +In that case the host first stops the owned runtime, waits for cleanup, +and invalidates its state before rolling back environment/configuration. + +Cleanup is shielded against cancellation of the hook caller. A cleanup failure +is reported as the sanitized `after_restore_failed` JSON error rather than +claiming rollback safely completed. Pending cleanup and rollback state remain +owned for a later retry. + +A client HTTP timeout/disconnect is not necessarily Python coroutine +cancellation. In the tested server configuration, work continues and a retry +waits behind the lifecycle lock. Explicit cancellation, shutdown, and process +loss remain distinct events. + +## Recovery policy remains unchanged + +Keep the existing recovery layers: initial scan, periodic scan, and inline +exact-task-ID reclaim through the task APIs. + +Initial LIST failure is logged and initialization continues under current +best-effort semantics. Per-record failures do not fail the complete scan. +Periodic recovery can retry later. Essential construction/uncaught startup +failures still fail activation explicitly. + +Do not change task scope/source filters, lease ownership, ETag fencing, or +normal heartbeat timing. The snapshot flag/lifecycle lock is not consulted by +running execution or renewal loops. + +Recovery re-enters a handler using externally saved task input and application +checkpoints. It does not resume the later Python frames of the crashed session. +The definition registry does not replace input persistence. + +## Responses integration + +Keep eager registration of `responses_resilient_one_shot` and +`responses_resilient_multi_turn`. Definitions are available before recovery +without rebuilding the complete Responses protocol graph. + +`TaskManagerNotInitialized` no longer necessarily means intentional opt-out. +When resilience is enabled and startup is deferred (the variable is +`true`), Responses propagates that initialization error through existing error +handling instead of silently running non-durably. When resilience is +intentionally disabled, or startup is not deferred (absent/`false`, including in-process +clients whose lifespan never ran), the existing in-process fallback remains. + +Deferred-start failures remove the exact caller-scoped execution record before +propagating the error, so an execution that never started is not retained. + +No new HTTP 503 contract is introduced. Normal platform request admission +occurs after successful restore. + +## Customer startup tasks: selected existing-handler design + +Declare `@task` or `@multi_turn_task` methods normally, then submit them from +`@app.after_restore_handler` after rebuilding the dependencies they use: + +```python +@app.after_restore_handler +async def initialize_session(context): + await rebuild_dependencies() + await startup_work.start(input={"session_id": context.session_id}) +``` + +The callback remains an ordinary async function receiving `AgentSessionContext`; +the submitted decorated method receives its normal `TaskContext`. Task methods +defined inside the handler also register with the prepared live manager. +Neither task declaration nor handler registration submits work during capture. +Calls in capture-time `__main__` are not automatically queued or replayed. + +Await `.start()` only for acceptance/its handle. `.run()` also works for bounded +work, but awaiting a long-running result delays the hook response and can exceed +the platform timeout. Put long result-dependent sequences in a launched durable +orchestration instead. + +The existing restore lock/completed-ID guard deduplicates concurrent and repeated +successful hooks. On failure, normal ownership/rollback rules preserve unfinished +records and inputs; a retry may execute the callback again. Use task IDs and +`get_active_run()` to reconcile unfinished submissions. A completed one-shot +record may be deleted, so once-per-logical-session behavior also needs an +application-owned durable completion marker. No generic exactly-once guarantee, +automatic task replay, or new receipt type is added. + +Opt-out remains authoritative per protocol. If no host requirement is enabled, +the callback still runs but task submission retains `TaskManagerNotInitialized`. +Setting the Invocations switch to `False` is not a global veto of an enabled +Responses option. +Ordinary cold startup remains unchanged and does not manufacture a restore event +or invoke this callback. + +## Historical startup alternatives (not selected) + +Registering decorated definitions is supported. Explicitly calling `.start()` +or `.run()` before the deferred manager exists is not automatically queued, +and restore does not rerun already completed `__main__`/lifespan statements. + +The following earlier alternatives are retained as design history, not the +current implementation. The selected approach above reuses the existing handler +by splitting preparation from recovery. Separate registry-prototype evidence +does not qualify the selected handler implementation. + +### Option A: explicit session-start callback + +Add a narrow callback invoked after TaskManager initialization on cold start +and restoration, never during capture. + +Illustrative API, **not implemented**: + +```python +@app.on_session_started +async def launch_session_work(context): + await bootstrap.start(input={"session_id": context.session_id}) +``` + +The callback reference is registered during process preparation and preserved +in the snapshot. It computes inputs from the real session and uses normal +task submission semantics. Customers must move launch statements into it. +It must be distinct from the existing pre-activation dependency callback. + +#### Execution order and customer migration + +```text +Capture: + declare decorated functions + register session-start callback + warm process without invoking the callback + capture both function references + +Cold startup / restore: + bind real session and prepare dependencies + initialize TaskManager and perform existing recovery + invoke session-start callback + finish initialization without waiting for LRA completion +``` + +The task definition stays in imports or main construction. The actual +`.start()` statements move into the callback. An unchanged capture-time +submission still fails; the callback does not intercept it, rerun `__main__`, +or serialize its arguments. + +The callback context needs the current session and, for restore, the +materialization ID. Cold initialization has no platform restore ID. Inputs +can therefore be computed from current session identity rather than retaining +capture-time placeholder values. + +This historical proposal assumed manager preparation and recovery were +inseparable. The selected existing-handler design instead prepares the manager +before the handler and delays recovery until afterward, removing the need for +that separate callback. + +#### Results, failure, and repetition + +Await `.start()` only until submission is accepted. A result-dependent +sequence should execute in a launched durable orchestration rather than block +the restore hook on long-running completion. + +Successful same-materialization callbacks can be deduplicated by restore ID; +an in-flight retry waits for the lifecycle lock. Failure before completion +permits another callback attempt. External effects of a partially executed +callback are not transactional. + +A fresh golden clone invokes the callback again. Its author must reconcile +already-recovered jobs and any durable completion marker before creating +another copy. The callback alone is not a once-per-logical-session job system. +Resilience enablement remains independent; registration must not silently +turn on a disabled task subsystem. + +#### Benefits and costs + +The contract is explicit, uses normal task handles after initialization, and +computes session-correct inputs. The cost is author migration and a new API. +It does not preserve arbitrary existing startup launch statements unchanged. +The callback alternative remains unselected and is not included in the +registry prototype. + +### Option B: startup launch-intent registry + +Keep existing launch-only code: + +```python +run = await startup_work.start(input={"index": "catalog"}) +# Do not await run.result() to complete capture-time startup. +``` + +Intercept the call before TaskManager lookup or task-store access. Recording +the intention requires no session, task record, lease, result future, or +execution worker. The caller receives an explicitly deferred receipt and +startup can continue. + +#### Collection boundary + +The prototype installs an owned registry at host construction. Under this +revised contract, collection must be allowed only while `true` intentionally +defers the runtime, resilience +is enabled, no manager is installed, and capture startup enrollment remains +open. Before-snapshot freezes enrollment. After-restore also freezes it before +dependency preparation. + +The earlier registry prototype also collected in absent mode; it must be aligned +and validated again before adoption. This PR does not implement that registry. + +HTTP/WebSocket request execution is excluded through an ASGI request boundary. +Missing-manager requests must not become queued startup work. Resilience +opt-out, invalid inputs, cold-mode calls before manager initialization, and +calls made before there is an owning host retain explicit failure behavior. +Once the manager exists, `.start()` takes the ordinary path. + +A composed lifespan that submits work after entering the original SDK +lifespan is a supported test placement: cold mode has a real manager, whereas +capture mode enrolls the same statement without executing its body. + +#### Recorded data + +Record each call separately, not just each method: + +| Field | Purpose | +|---|---| +| Intention ID | Stable identity for this captured launch | +| Function reference and declared options | Preserve the actual handler and per-call task options | +| Frozen input and input type | Preserve submitted values and reconstruct supported typed input | +| Explicit task ID or automatic-ID policy | Keep caller identity or bind it after session assignment | +| Input ID and input precondition | Preserve chain semantics | +| Submission order | Retain launch order, not completion order | + +Freeze JSON-compatible inputs at enrollment, including model serialization. +Reject nonportable live objects/nonfinite values rather than retaining mutable +references or silently changing them. Keep session-dependent behavior inside +the task body or explicitly redesign its input; freezing an absent session ID +does not make it correct after restoration. + +Captured functions/options remain process references. This is not a second +storage backend or a serialization of arbitrary Python resources. + +#### Deferred receipt + +The prototype uses a distinct `DeferredTaskRun`, rather than pretending an +already running `TaskRun` exists: + +| Operation | Before activation | After binding | +|---|---|---| +| `is_deferred` | True | False | +| `intent_id` | Available, immutable | Same intention identity | +| `task_id` | Explicit requested ID, or None for automatic ID | Actual submitted task ID | +| `input_id` | Requested value or None | Accepted run's input ID | +| `result()` / awaiting receipt | Explicit initialization error; never wait for capture to finish | Delegate to actual run | +| `cancel()` | Cancel enrollment without task-store access | Delegate to actual run | + +Normal `TaskRun` keeps its existing shape and execution semantics, with an +`is_deferred=False` observation. The prototype widens `.start()`'s annotated +return to include the deferred receipt only for the newly supported case. +That API/type shape needs review; it is not a claim of release compatibility. + +#### Activation and recovery + +```text +Real-session binding and dependency preparation + -> provide captured startup definitions/options to the manager + -> initialize manager; immediately recover eligible persisted tasks + -> reconcile each recorded intention + -> bind receipt to recovered run or submit through ordinary .start() + -> finish restore without waiting for task completion +``` + +Automatic IDs for **new submissions** include the captured intention, actual +agent/session, and platform `restore_id` when available: + +```text +snapshot: SHA256(agent_name : session_id : intent_id : restore_id) +no restore ID: SHA256(agent_name : session_id : intent_id) +``` + +The second form is unchanged for registry activation without a restore ID. +Ordinary non-snapshot startup still uses its existing task submission path and +ID generation; it does not manufacture a platform restore ID. + +FHA generates a new restore ID for a new golden materialization, crash +replacement, idle restoration, and qualifying in-place memory resume. Retries +and concurrent hook calls for the same restoration reuse that ID. Normal +requests and lease renewal do not change it. + +**Recover first, allocate second.** Match a captured intention to unfinished +startup work by its stable intention marker and actual session before deriving +a new launch ID. A recovered run retains its persisted task ID even though a +fresh submission in the new restoration would have a different ID. Retain the +recovered handle if a short job completes before the registry drain. + +When no unfinished match exists, create new work. A completed/deleted one-shot +job is not recreated with its old ID: the new materialization gets a new +restore-scoped ID. This addresses the hosted create409 that previously failed +idle after-restore. + +The existing best-effort manager scan remains unchanged. Query failure or an +unreclaimed in-progress startup record is not proof of absence; registry drain +surfaces the uncertainty/conflict rather than launching another copy. +Same-restoration retry IDs are deterministic, and valid accepted launches are +not repeated. + +Explicit user task IDs remain unchanged. Unrelated marker/source conflicts +fail, rather than silently attaching or rewriting the caller's identity. + +The prototype persists an internal intention marker with normal task creation. +Initial/inline recovery uses that marker to recover the captured handler, +input type, and options. Recovered run handles are retained through startup +reconciliation even if a short one-shot task finishes and deletes its record +before the registry is drained. Otherwise recovery followed by replay could +start it twice in the same materialization. + +Accepted intentions are checkpointed by runtime/materialization. Same-ID +retries do not resubmit valid accepted work; an unfinished drain retries +remaining work. Runtime disposal invalidates unfinished acceptance, not a +successful result that the receipt already holds. If a later intention fails +and the manager is rebuilt for the same restore ID, previously successful +completed intentions remain deduplicated. A different restore ID can create +fresh startup work when no unfinished job exists. Partial submission failure +follows the existing worker cleanup/configuration rollback path. + +No change to worker timing, task storage backend, lease fencing, or immediate +best-effort discovery policy is required. + +#### Limits and rollout + +It cannot transparently complete startup that requires: + +```python +value = await prepare.run(input=...) +await bootstrap.start(input=value) +``` + +The result does not exist during capture. Waiting for it can prevent readiness +and capture. The same issue applies to `.start()` followed by `.result()`. +Inputs tied to session identity/live resources need restore-time rebinding. +An unawaited coroutine has not submitted anything to record. + +The registry only preserves launch intentions that actually execute the +supported submission path before enrollment freezes. It does not replay all +startup Python statements, supply capture-time task results, or repair missing +dependencies of an immediately recovered task. + +Cancellation before capture can remain part of the template. Cancellation or +completion that happens later in a session is not written back into the golden +registry. Applications needing permanent logical-session cancellation or +once-only effects must persist and reconcile that state externally. + +The advantage is preserving existing non-blocking startup launches without a +new callback. Costs include the deferred receipt contract, collection boundary, +input/identity policy, and explicit replay reconciliation. Testing must cover +actual startup calls, not merely decorator registration. + +### Shared duplicate-launch concern + +Both options must specify per-materialization versus once-per-logical-session +behavior. Every golden clone begins with the original callback/intent state. +In-memory restore checkpoints do not establish cross-clone once-only effects. +Stable task IDs help, but one-shot records are deleted at terminal exit. +Once-per-session guarantees need durable reconciliation or application +idempotency; neither option provides generic exactly-once execution. + +Neither historical option is selected. Option B's separate restore-scoped +prototype has its own local and hosted evidence below, but is not included in +this implementation or proposed as a released API. + +## Persistence boundaries and limitations + +Hosted task and response stores remain external. Application checkpoint +storage remains application-owned. Default local stream replay and arbitrary +post-capture local files are not guaranteed to survive golden replacement. + +This task lifecycle change does not declare every captured provider/client +safe. Applications still need appropriate dependency rebinding. Existing +typed-input behavior is not broadened or redesigned. + +## Validation and outstanding qualification + +| Scenario | Evidence / status | +|---|---| +| True deferral; absent/false cold startup; opt-out; malformed input | Targeted startup tests | +| Definitions before activation; restored binding | Core/Responses activation tests | +| Independent protocol enablement across snapshot/cold modes | Responses options vs Invocations switch, mixed MRO, HTTP/WebSocket/Voice, callback submission and host-isolation matrices | +| Owning protocol recovery after restore | One-shot/multi-turn callback and provider ownership with another host constructed before/after startup | +| Same-ID concurrent/duplicate hooks | Real ASGI tests; one runtime/scanner | +| Partial initialization/scanner failure | Completed-phase reuse tests | +| Actual cancellation and environment rollback | Worker quiescence and retry tests | +| Client timeout/retry | Loopback HTTP test | +| Recovery LIST failure | Best-effort completion and periodic retry test | +| Recovery/lease filtering/fencing | Existing targeted task suites | +| Execution/renewal during hook | Real recovered task/heartbeat integration test | +| Application handler task submission | Decorated `.start()`/bounded `.run()`, nested definitions, no automatic recovery before dependencies, failed/cancelled-hook retries | +| Inactive provider configuration changes | Cancelled initial scan followed by omitted endpoint override rebuilds the provider | +| Shutdown admission/quiescence | Child-in-finally rejection, in-flight create settlement, non-inherited admission and nonblocking run-result waits | +| Agent/default-directory rollback | Matching-session agent rename and default HOME changes stop incompatible work before rollback | +| Cold and initial golden LRA activation | Controlled hosted sample, actual external lease renewal | +| Hard crash after checkpoints 1 and 2 | Controlled hosted recovery; no new invocation during orphan interval | +| Independent sessions/concurrent tasks | Controlled hosted owner/worker/lease observations | +| Cancellation | Controlled hosted terminal cancellation, no task resurrection during observation | +| Natural idle/resume | Controlled hosted test: 120-second policy; after six quiet minutes, same session/capture with a new restore ID and worker, followed by renewed leases and completion | +| Historical Option B startup submissions | Separate unselected prototype; not qualification of the existing-handler implementation | + +The controlled hosted test runs the existing resilient streaming sample. +A test-only wrapper exits the process with code 137 after a committed +checkpoint; it does not change SDK recovery or lease timing. An external +one-shot marker prevents repeated crash injection. A capture-only heap marker +is retained through restoration, together with a new restore ID/worker and +incremented fencing generation, to distinguish replacement from cold import. + +For idle/resume, management continued reporting the logical session as active. +The test closed its log reader before the quiet interval and invoked the same +session only once afterwards. The new restore ID/worker, retained capture heap +marker, and subsequent lease-backed completion establish restoration; elapsed +quiet time or management status alone would not. This does not establish the +exact suspension timestamp or a guarantee of suspension at exactly 120 seconds. + +These observations do not replace CI, public rollout qualification, or an +unobserved scenario. + +### Historical, unselected Option B prototype qualification + +The fixed registry prototype passed 220 selected local/package tests and nine +controlled hosted cases. The hosted sample executes a real decorated startup +task through a composed ordinary lifespan, not a session-start callback: + +| Case | Observation | +|---|---| +| Cold startup | Original non-snapshot task creation and lease behavior | +| Absent-flag capture/restoration (previous prototype contract) | Deferred intention/no runtime at capture; real leased launch after SDK activation | +| Completed startup followed by natural idle | New restore ID and fresh startup task ID, without the prior create409/hook500 | +| Crash while startup is unfinished | Original task ID retained, fencing generation increments, saved progress resumes | +| Crash after startup completes | New materialization creates fresh startup work with a new automatic ID | +| Checkpoint2 crash after startup completes | Original response resumes at phase3; fresh startup work completes | +| Independent sessions and concurrent responses | Session-scoped identities, one manager/startup job and independent leases | +| Response cancellation | Target response stays cancelled without resurrecting or cancelling the independent startup job | +| Literal true SDK flag | Set in a separate controlled image; capture defers and restored work leases normally | + +Task-store observations establish actual lease acquisition/renewal, worker +identity and fencing. Capture/restore logs establish materialization ordering. +Crash runs prohibit request-driven recovery before independent task-store +reclaim evidence. The idle run closes logs, waits six quiet minutes after all +initial work completes, then invokes the same session once; exact suspension +time is not inferred. + +The previous registry version failed after completed task deletion because it +reused the same automatic ID. Including an available restore ID for **fresh** +work resolved that hosted conflict while preserving unfinished-task recovery. +Application completion state is not a generic exactly-once guarantee. + +Explicit caller IDs, same-restore failure/retry, and fault-injected discovery +failure are covered by the local suite, not separate hosted fault runs. +Setting true in the image does not prove automatic platform flag injection or +support for a reserved agent-definition environment override. diff --git a/sdk/agentserver/azure-ai-agentserver-core/docs/streaming-guide.md b/sdk/agentserver/azure-ai-agentserver-core/docs/streaming-guide.md index e26fc47fc6d9..a3a60546f9d5 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/docs/streaming-guide.md +++ b/sdk/agentserver/azure-ai-agentserver-core/docs/streaming-guide.md @@ -67,7 +67,7 @@ against the `EventStream` Protocol. |---|---|---|---|---| | `use_in_memory_live()` (default) | Single subscriber that attaches before the producer; lowest memory; you don't need late subscribers to catch up. | No — late subscribers miss earlier events. | No. | Constant memory: only the subscriber list, no event buffer. | | `use_in_memory_replay(...)` | Multiple subscribers that may attach at different times; client may reconnect within `ttl_seconds`. | Yes (within the per-event TTL window). | No. | Each event is retained until its TTL elapses (or `delete` runs). | -| `use_file_backed_replay(...)` | Long-running turns where you need to survive a process crash and a fresh worker resuming the same turn. | Yes. | Yes — events are persisted to `storage_dir / f"{id}.jsonl"` and rehydrated on the next `get_or_create(id)`. | Single-writer-per-file enforced. | +| `use_file_backed_replay(...)` | Long-running turns where you need to survive a process crash and a fresh worker resuming the same turn. | Yes. | Yes — events are persisted to `storage_dir / f"{id}.jsonl"` and rehydrated by `get(id)` or `get_or_create(id)`. | Single-writer-per-file enforced. `get(id)` never creates an absent log. | **Call a configurator before you create any streams** (typically once at app startup). Later calls only affect streams created @@ -239,7 +239,7 @@ be destroyed; once destroyed, every operation against it raises Three independent paths into destroyed: -- the id was **never registered** (no `get_or_create(id)` for it ever ran); +- the id has **no registered stream or retained file-backed replay log**; - the id was **explicitly `streams.delete(id)`**d; - the id's stream was **Closed** and its close-clock TTL (`close_time + ttl_seconds`) **elapsed** — only applies to replay @@ -262,13 +262,16 @@ A few practical implications: ## The registry ```python -streams.get(id) -> EventStream # raises NotFound for any id that is not currently live +streams.get(id) -> EventStream # registered or retained file replay; never creates absent logs streams.get_or_create(id) -> EventStream # idempotent streams.delete(id) -> None # idempotent ``` -- `get(id)` returns the registered stream, or raises - `EventStreamNotFoundError`. Treat any `NotFound` uniformly: +- `get(id)` returns the registered stream or, with file-backed replay + configured, restores an existing log after restart. It never creates + an absent log. Missing, deleted, or expired streams raise + `EventStreamNotFoundError`. File access and lock failures propagate + rather than being treated as missing. Treat any `NotFound` uniformly: "this id is not a live stream; subscribe to a new id or treat as missing". - `get_or_create(id)` is idempotent — every caller using the same @@ -276,8 +279,22 @@ streams.delete(id) -> None # idempotent coroutines. If the id was previously destroyed, a fresh stream is created. - `delete(id)` removes the stream and any backing resources (including - the on-disk log for file-backed replay). Idempotent — safe to call - on an unknown or already-deleted id. + a retained file-backed log not yet loaded after restart). Idempotent — safe to call + on an unknown or already-deleted id. If file removal fails, the error + propagates and no deletion tombstone is installed; retry `delete(id)` + after correcting the backing-storage problem. Expiry cleanup follows + the same rule. + +All three lifecycle operations use the same per-id lock within the registry, +so concurrent lookup, restoration, creation, and deletion are serialized. +This is in-process atomicity, not cross-process coordination. + +Per-ID locks exist only while lifecycle operations hold or wait for them. +Each operation registers its reference before awaiting the lock; cancellation +releases that reference without awaiting further work. The entry is removed +only after its last holder or waiter exits, so missing lookups do not accumulate +locks and concurrent operations cannot switch to a different lock mid-lifecycle. +Stream slots and deletion tombstones retain their existing lifecycle semantics. You typically do not need to call `delete(id)` for replay backings with `ttl_seconds` configured — the close-clock auto-destroy diff --git a/sdk/agentserver/azure-ai-agentserver-core/docs/task-and-streaming-spec.md b/sdk/agentserver/azure-ai-agentserver-core/docs/task-and-streaming-spec.md index 306a6bf76d07..39d109547120 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/docs/task-and-streaming-spec.md +++ b/sdk/agentserver/azure-ai-agentserver-core/docs/task-and-streaming-spec.md @@ -1859,7 +1859,8 @@ Implementation MUST: gesture (§23.10).** - **Support list-filter parity (§31a)** — `has_error`, `lease_expired`, pagination via `after` cursor (plain `task_id` for local; opaque - service token for hosted), `limit` (default 20, max 100), `order` + service token for hosted), `limit` (default `None` for all + matches; explicit positive total cap clamped to 100), `order` asc/desc by `created_at`, reject `before`, normalize "done" → "completed" in the status filter, `agent_name` + `session_id` optional. @@ -1985,7 +1986,15 @@ backings. **Pagination**: -- `limit` defaults to 20, max 100 (provider clamps over-cap to 100). +- The Python provider `list()` defaults to `limit=None`, meaning complete + enumeration of every matching task, including all hosted continuation pages. + An explicit positive `limit` bounds the **total returned tasks**, clamped to + 100, not the hosted service page size. Both backings reject nonpositive limits. + Hosted requests use at most 100 tasks per page and reduce that page size to + the remaining result budget for bounded calls. +- The raw Task Storage service's `limit` remains a **page-size** parameter + (default 20, max 100); its default does not bound Python provider scans. + Recovery and response-ownership scans explicitly request `limit=None`. - `after` is an opaque cursor string. The local provider uses plain `task_id` (no Cosmos continuation-token concept). The hosted provider round-trips whatever opaque token the service @@ -1996,7 +2005,16 @@ backings. - `order` accepts `"asc"` or `"desc"`. Default `"desc"`. Sorts by `created_at`. -**Response**: +**Python provider return**: + +- A materialized `list[TaskInfo]`, not a page envelope. It contains all matches + when `limit=None`, or at most the clamped explicit limit. Listing failures + propagate rather than returning an apparently complete partial scan. +- Hosted continuation is consumed internally. A supplied `after` token is + forwarded unchanged; task IDs must not be substituted for service tokens. + The Python list return does not expose a hosted next-page token. + +**Raw service page response**: - `Data` — the page of tasks (or DTOs). - `LastId` — the opaque continuation cursor to pass back as `after` @@ -2572,7 +2590,7 @@ streams.use_file_backed_replay(cursor_fn=...) # configurator ( # resolve_state_subdir("streams"); ttl_seconds defaults to 600 (10 min); # serializer/deserializer default to JSON. Explicit args override. -await streams.get(id) # raises NotFound if never registered +await streams.get(id) # registered or retained file replay; never creates absent logs await streams.get_or_create(id) # atomic per id await streams.delete(id) # idempotent; installs tombstone ``` @@ -2580,12 +2598,19 @@ await streams.delete(id) # idempotent; installs tombstone Six methods total: three sync configurators + three async lifecycle methods. -Atomicity: `get_or_create(id)` MUST be safe under concurrent -callers. The implementation uses a per-id lock to prevent -split-brain construction when two coroutines race to create the -same id. The lock is acquired only on the slow path (first -access for an id); subsequent `get_or_create` calls return the -cached instance without taking the lock. +Atomicity: `get(id)`, `get_or_create(id)`, and `delete(id)` MUST +share a per-id lock within the registry. Lookup, restoration, +creation, and deletion are serialized, preventing split-brain +construction for concurrent callers. This guarantee is in-process, +not cross-process coordination. + +With file-backed replay configured, `get(id)` restores an existing +log even when the id has not been registered in the current process. +It MUST NOT create an absent log. A restored stream is subject to the +same close-clock expiry check as a cached stream. File access and lock +failures propagate instead of being converted to NotFound. +`delete(id)` also restores and removes a retained log that has not +yet been loaded after restart. Tombstones: `delete(id)` causes the next `get(id)` against that id to raise `EventStreamNotFoundError`. The registry uses an @@ -2596,7 +2621,7 @@ not currently a live stream" condition raises `EventStreamNotFoundError`. This covers all three paths into the missing-stream state: -- the id was never registered; +- the id has no registered stream or retained file-backed replay log; - the id was registered and then explicitly `delete(id)`d; - the id was registered, then transitioned to Closed, then the TTL-since-close clock elapsed (§46) and the registry @@ -2627,6 +2652,9 @@ attempt to use an id that is not currently a live stream raises Each `use_*` configurator replaces the registry's stream factory **globally for the process**. Subsequent `get_or_create(id)` calls use the new factory; existing stream instances are unaffected. +Cold `get(id)` and `delete(id)` lookups restore existing logs only +when file-backed replay is configured. Switching to an in-memory +backing disables disk lookup for unregistered ids. Configurators are synchronous and idempotent. The default factory (if no configurator is called) produces `BroadcastEventStream` instances. @@ -2817,7 +2845,7 @@ EventStreamError # base ├── EventStreamClosedError # emit on closed stream └── EventStreamNotFoundError # any "id is not currently a # live stream" condition — - # never registered, deleted, + # no registered or retained stream, deleted, # or close-clock elapsed ``` @@ -2838,7 +2866,7 @@ exception: | Path to NotFound | Broadcast (live) | Replay (in-memory) | Replay (file-backed) | |---|---|---|---| -| 1. `get(id)` for an id that was never registered. | ✓ | ✓ | ✓ | +| 1. `get(id)` for an id with no registered stream or retained replay log. | ✓ | ✓ | ✓ (an existing log is restored, not reported missing) | | 2. Explicit `streams.delete(id)` → instance removed + registry tombstones the id. Works in ANY state (Active or Closed). | ✓ | ✓ | ✓ (file removed before tombstone) | | 3. Closed stream's close-clock elapses (`now >= close_time + ttl_seconds`) → registry tombstones the id. Requires the backing to have been constructed with `ttl_seconds`. | ✗ (no TTL) | ✓ | ✓ (file removed before tombstone) | @@ -3772,21 +3800,30 @@ Items are grouped by area. Each item is identified `C-AREA-N` (`get`, `get_or_create`, `delete`). - **C-STR-REG-2.** Default backing MUST be `BroadcastEventStream` (live, no buffer). -- **C-STR-REG-3.** `get_or_create(id)` MUST be atomic under - concurrent callers (per-id lock). +- **C-STR-REG-3.** `get(id)`, `get_or_create(id)`, and `delete(id)` + MUST share a per-id lock within the registry so lookup, + restoration, creation, and deletion are serialized. - **C-STR-REG-4.** `delete(id)` MUST be idempotent and MUST install a tombstone (even for ids that were never registered) so a subsequent `get(id)` raises `EventStreamNotFoundError`. + With file-backed replay configured, it MUST also remove an existing + retained log that has not been loaded in the current process. + Backing deletion failures MUST propagate before installing the tombstone, + leaving cleanup retryable. This also applies to close-clock expiry cleanup. - **C-STR-REG-5.** Tombstone MUST be cleared on the next `get_or_create(id)` for the same id. - **C-STR-REG-6.** `get(id)` MUST raise `EventStreamNotFoundError` for ANY id that is not currently a live stream — whether it - was never registered, was explicitly `delete(id)`d, or had its + has no registered stream or retained log, was explicitly `delete(id)`d, or had its close-clock elapse (§46). `get(id)` MUST NOT itself install a tombstone (only `delete(id)` and the close-clock auto-tombstone do). There is no `EventStreamGoneError` — that error type has been removed; every "id is not live" condition surfaces uniformly as `EventStreamNotFoundError`. +- **C-STR-REG-7.** With file-backed replay configured, `get(id)` + MUST restore an existing retained log without creating an absent + log and MUST apply the close-clock expiry check after restoration. + File access and lock failures MUST propagate, not become NotFound. ### C-STR-TTL (replay TTL) @@ -3834,10 +3871,34 @@ Items are grouped by area. Each item is identified `C-AREA-N` (no verbatim id can alias another id's hash). - **C-STR-FBR-2.** Constructor MUST rehydrate from an existing file (crash-recovery friendly). + Cold registry `get()` MUST open the existing log in non-creating mode, + including when it disappears after an earlier existence observation. + Absence reported by that file open, or by the post-lock check that its + descriptor still names the backing path, means a missing stream. A descriptor + for a replaced inode MUST be rejected rather than used for replay or writes. + Backing-lock acquisition failures MUST propagate, even when they are + `FileNotFoundError`. + `get_or_create()` retains the creating constructor and rehydrates existing + logs. Compaction MUST reopen its replacement log in non-creating mode. - **C-STR-FBR-3.** Optional `serializer` / `deserializer` callbacks MUST be honored for non-JSON payloads. Default uses JSON. - **C-STR-FBR-4.** `delete()` and the close-clock auto-tombstone MUST clean up the file before the registry tombstones the id. + Cold `delete()` MUST open only an existing log, acquire the same + single-writer file lock, and remove the backing resources without + rehydrating or deserializing events. Malformed records or failing + payload callbacks MUST NOT prevent deletion. An absent log MUST NOT + be created. File-access, lock-acquisition, and cleanup failures MUST + propagate without installing a successful deletion tombstone; an + allocated cleanup owner MUST remain available for exact-owner retries, + including Windows lock-file cleanup and cancellation. + POSIX inode-lock ownership MUST be retained through removal of the log's + directory entry. Windows MUST acquire its sidecar lock before opening the + log and retain that ownership while closing and removing the log, releasing + the sidecar only afterward. Removal MUST verify the owned backing identity; + a replaced backing MUST NOT be unlinked without reacquiring its ownership. + Once the owned log has been removed, cleanup retries MUST release only the + remaining owned resources, not unlink a subsequently created backing path. - **C-STR-FBR-5.** **File format.** Each emitted event is a single JSONL line wrapping the payload + arrival time: @@ -3857,12 +3918,22 @@ Items are grouped by area. Each item is identified `C-AREA-N` NOT at a terminal-record timestamp. A terminal record MUST be accepted even when it carries no `emit_time`; a NON-terminal record missing `emit_time` remains malformed and MUST raise. + An observed final terminal state and its close-clock anchor MUST be set + before recovery-time TTL eviction can compact the log. Compaction MUST + preserve that terminal marker, including when 1,000 or more expired events + precede surviving events, so a subsequent restart still restores CLOSED. - **C-STR-FBR-6.** **Rehydration robustness.** Constructor MUST tolerate a trailing partial line (e.g. from a crash mid-write) by truncating it. Mid-file malformed JSON lines MUST raise (corruption signal, not recoverable). The TERMINAL sentinel, if present anywhere mid-file, MUST be ignored unless it is the final line. + Every exceptional constructor exit after opening the file, including + deserializer/cursor callbacks and cancellation, MUST release the exact + resources acquired by that constructor. Cleanup errors MUST remain visible + with the original failure context. If cleanup fails, the registry MUST + retain the exact, stream-ID-bound cleanup owner for retry without exposing + it as a usable replay or releasing another stream's resources. - **C-STR-FBR-7.** **Concurrency.** Implementations MUST use a single-writer lock (POSIX `fcntl` advisory lock preferred, `.lock` sentinel-file fallback) to prevent two processes from @@ -3872,6 +3943,15 @@ Items are grouped by area. Each item is identified `C-AREA-N` implementations SHOULD rewrite the file to compact away evicted lines (avoids unbounded file growth on long-lived streams with short TTLs). + Before successful replacement, a preparation failure MAY retain the original + valid writer, but MUST be logged and temporary-file cleanup failures MUST + propagate. Cancellation MUST propagate after temporary-file cleanup. + After successful replacement, reopen/lock/seek/handle-retirement failures + MUST propagate and permanently prevent successful writes through the stale + inode. Owned handles MUST be retired with retryable cleanup; subscribers + MUST observe an explicit failure rather than hang or report normal completion. + Deleting a failed replacement MUST reacquire its backing-file ownership, + retaining any allocated deletion owner through cleanup failure or cancellation. ### C-OUT (output persistence) — *removed* @@ -3959,8 +4039,10 @@ outputs that must survive crashes are the handler's responsibility AND semantics), `source_type`, `status` (with legacy `"done"` → `"completed"` normalization). - **C-PRV-10.** `provider.list(...)` MUST support pagination via - opaque `after` cursor + `limit` (default 20, max 100, provider - clamps over-cap). `before` MUST be rejected as `invalid_request` + opaque `after` cursor + `limit` (Python default `None` enumerates all + matches; an explicit positive limit caps total results and is clamped + to 100). The raw service defaults to a page size of 20, not a total + provider result limit. `before` MUST be rejected as `invalid_request` (cursor pagination forward-only). `order` accepts `"asc"` or `"desc"` by `created_at` (default `"desc"`). Per §31a. - **C-PRV-11.** `provider.list(...)` MUST support @@ -3971,7 +4053,10 @@ outputs that must survive crashes are the handler's responsibility (`LastId` / `next_page_token`) MUST be treated as opaque by the framework. The local provider mints its own cursor (plain `task_id`); the hosted provider round-trips whatever opaque - token the service returns (up to 4096 chars). + token the service returns (up to 4096 chars) internally when continuing + its scan. Python provider `list()` returns `list[TaskInfo]`, not the + service page envelope. Missing or repeated continuation cursors MUST + raise when further enumeration is needed, not return partial success. ### C-OBS (observability — minimal) diff --git a/sdk/agentserver/azure-ai-agentserver-core/docs/tasks-guide.md b/sdk/agentserver/azure-ai-agentserver-core/docs/tasks-guide.md index 686482afc2fc..01279fb58de4 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/docs/tasks-guide.md +++ b/sdk/agentserver/azure-ai-agentserver-core/docs/tasks-guide.md @@ -115,10 +115,11 @@ What this primitive deliberately does **not** do: ### Enabling resilient tasks -The resilient task subsystem is **opt-in**. The `TaskManager` is constructed — -and its **startup recovery scan** (a network round-trip to the hosted task store -that reclaims tasks left in-flight by a crashed prior instance) plus the -periodic recovery loop run — **only when the switch is on**: +For direct Core task APIs and the Invocations protocol, the resilient task +subsystem is **opt-in**. The host requests a `TaskManager` — including its +**startup recovery scan** (a network round-trip to the hosted task store that +reclaims tasks left in-flight by a crashed prior instance) and periodic +recovery loop — when the switch is on: ```python from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled @@ -126,20 +127,25 @@ from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled set_resilient_tasks_enabled(True) # opt in BEFORE host startup (e.g. at import) ``` -Use `resilient_tasks_enabled()` to read the current switch state. +Use `resilient_tasks_enabled()` to read the current switch state. This +process-wide switch controls direct Core task use and is the opt-in used by the +Invocations protocol. Merely declaring a durable task (`@task` / `@multi_turn_task`) does **not** turn -the subsystem on. When the switch is off, no `TaskManager` is installed: -`get_task_manager()` raises `TaskManagerNotInitialized` and `.run()` / `.start()` -cannot run a task. A server that does not set the switch (e.g. an -invocations-only host) constructs no manager and pays none of the recovery-scan -latency. - -> **Protocol note:** the responses protocol exposes a `resilient_background` -> server option that maps to this switch — constructing a -> `ResponsesAgentServerHost(options=ResponsesServerOptions(resilient_background=True))` -> enables the subsystem automatically, so responses apps typically don't call -> `set_resilient_tasks_enabled` directly. +the subsystem on. Without this switch or a protocol-scoped startup request, no +`TaskManager` is installed: `get_task_manager()` raises +`TaskManagerNotInitialized` and `.run()` / `.start()` cannot run a task. A +plain host that requests neither path constructs no manager and pays none of +the recovery-scan latency. + +> **Protocol note:** the Responses protocol does not read or change this +> process-wide switch. A `ResponsesAgentServerHost` requests protocol-scoped +> `TaskManager` access at startup when either `resilient_background=True` or +> `steerable_conversations=True`. When both options are `False`, calling +> `set_resilient_tasks_enabled(True)` elsewhere cannot force Responses onto its +> task runtime. This keeps mixed-protocol hosts isolated: enabling Responses +> does not activate Invocations, and enabling Invocations does not activate +> Responses. ### One-shot @@ -147,8 +153,7 @@ latency. import asyncio from azure.ai.agentserver.core.tasks import task, TaskContext, set_resilient_tasks_enabled -# Optional: force-enable recovery even before the first task is declared. -# Declaring the @task below already enables recovery on its own. +# Required for direct Core task use; call before host startup. set_resilient_tasks_enabled(True) @task(name="summarize") @@ -171,8 +176,7 @@ asyncio.run(main()) import asyncio from azure.ai.agentserver.core.tasks import multi_turn_task, TaskContext, set_resilient_tasks_enabled -# Optional: force-enable recovery even before the first task is declared. -# Declaring the @multi_turn_task below already enables recovery on its own. +# Required for direct Core task use; call before host startup. set_resilient_tasks_enabled(True) @multi_turn_task(name="chat") diff --git a/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml index 1fbd92d07b50..236795391fe6 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] license = "MIT" classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_file_backed_replay_event_stream.py b/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_file_backed_replay_event_stream.py index 957c64aa2553..d71cc624a6d4 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_file_backed_replay_event_stream.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_file_backed_replay_event_stream.py @@ -16,8 +16,10 @@ import asyncio import json +import multiprocessing import os from pathlib import Path +from types import SimpleNamespace import pytest @@ -33,6 +35,26 @@ pytestmark = pytest.mark.asyncio(loop_scope="function") +def _delayed_replay_writer(path, connection): + class DelayedWriter(FileBackedReplayEventStream): + def _acquire_file_lock(self): + connection.send("opened") + connection.recv() + super()._acquire_file_lock() + + stream = None + try: + stream = DelayedWriter(path=Path(path)) + asyncio.run(stream.emit({"n": "waiter"})) + connection.send("acknowledged") + except Exception as error: + connection.send(type(error).__name__) + finally: + if stream is not None: + stream._cleanup_locks() + connection.close() + + # ---------------------------------------------------------------- # Rule 26 — persist-before-fanout # ---------------------------------------------------------------- @@ -269,6 +291,237 @@ async def test_mid_file_malformed_raises_at_construction(self, tmp_path: Path) - # ---------------------------------------------------------------- +class TestRehydrationResourceCleanup: + @pytest.mark.parametrize("callback", ["deserializer", "cursor"]) + @pytest.mark.parametrize("failure", [ValueError, FileNotFoundError, asyncio.CancelledError, GeneratorExit]) + @pytest.mark.parametrize("backend", ["windows", "posix", "native"]) + async def test_callback_failure_releases_exact_owned_resources_and_allows_delete( + self, tmp_path: Path, monkeypatch, callback, failure, backend + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + + if backend == "windows": + monkeypatch.setattr(_concrete, "fcntl", None) + elif backend == "posix": + monkeypatch.setattr(_concrete, "fcntl", SimpleNamespace(LOCK_EX=1, LOCK_NB=2, flock=lambda *args: None)) + path = tmp_path / "failed-recovery.jsonl" + path.write_text(json.dumps({"emit_time": 1, "payload": {"n": 1}}) + "\n", encoding="utf-8") + foreign = FileBackedReplayEventStream(path=tmp_path / "foreign.jsonl") + owners = [] + descriptors = [] + rehydrate = FileBackedReplayEventStream._rehydrate + primary = failure("callback failed") + + def observe(owner): + owners.append(owner) + descriptors.append(owner._lock_fd) + rehydrate(owner) + + def failed(*args): + raise primary + + monkeypatch.setattr(FileBackedReplayEventStream, "_rehydrate", observe) + registry = _StreamsRegistry() + registry.use_file_backed_replay( + storage_dir=tmp_path, + cursor_fn=failed if callback == "cursor" else None, + serializer=lambda payload: json.dumps(payload).encode("utf-8"), + deserializer=failed if callback == "deserializer" else json.loads, + ) + try: + with pytest.raises(failure) as caught: + await registry.get("failed-recovery") + assert caught.value is primary + assert len(owners) == 1 + assert owners[0]._file.closed + assert owners[0]._lock_fd is None + assert owners[0]._lock_path is None + assert not path.with_suffix(".jsonl.lock").exists() + assert registry._slots == {} + assert registry._id_locks == {} + if descriptors[0] is not None: + with pytest.raises(OSError): + os.fstat(descriptors[0]) + assert not foreign._file.closed + if foreign._lock_fd is not None: + os.fstat(foreign._lock_fd) + assert foreign._lock_path.exists() + if backend == "posix" and os.name == "nt": + monkeypatch.setattr(_concrete, "fcntl", None) + await registry.delete("failed-recovery") + assert not path.exists() + finally: + if backend == "posix" and os.name == "nt": + foreign._cleanup_locks() + await foreign._on_delete() + + @pytest.mark.parametrize("artifact", ["file", "descriptor", "lock"]) + @pytest.mark.parametrize("operation", ["direct", "get", "get_or_create"]) + @pytest.mark.parametrize("failure", [ValueError, asyncio.CancelledError]) + async def test_cleanup_failure_surfaces_primary_and_preserves_exact_retry_resources( + self, tmp_path: Path, monkeypatch, artifact, operation, failure + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry, _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "failed-cleanup.jsonl" + path.write_text(json.dumps({"emit_time": 1, "payload": 1}) + "\n", encoding="utf-8") + primary = failure("cursor failed") + cleanup_error = PermissionError("cleanup denied") + owners = [] + rehydrate = FileBackedReplayEventStream._rehydrate + close_descriptor = os.close + unlink = Path.unlink + active = True + + class FileHandle: + def __init__(self, handle): + self.handle = handle + + def __getattr__(self, name): + return getattr(self.handle, name) + + def close(self): + if active and artifact == "file": + raise cleanup_error + self.handle.close() + + def observe(owner): + owners.append(owner) + owner._file = FileHandle(owner._file) + rehydrate(owner) + + def denied_close(descriptor): + if active and artifact == "descriptor" and descriptor == owners[0]._lock_fd: + raise cleanup_error + close_descriptor(descriptor) + + def denied_unlink(candidate, *args, **kwargs): + if active and artifact == "lock" and candidate == path.with_suffix(".jsonl.lock"): + raise cleanup_error + return unlink(candidate, *args, **kwargs) + + def failed(payload): + raise primary + + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=failed) + with monkeypatch.context() as patch: + patch.setattr(FileBackedReplayEventStream, "_rehydrate", observe) + patch.setattr(_concrete.os, "close", denied_close) + patch.setattr(Path, "unlink", denied_unlink) + with pytest.raises(PermissionError) as caught: + if operation == "direct": + FileBackedReplayEventStream(path=path, cursor_fn=failed) + else: + await getattr(registry, operation)("failed-cleanup") + assert caught.value is cleanup_error + assert caught.value.__context__ is primary + owner = owners[0] + assert owner._lock_path == path.with_suffix(".jsonl.lock") + assert owner._lock_path.exists() + assert owner._file.closed is (artifact != "file") + assert (owner._lock_fd is None) is (artifact == "lock") + assert owner._buffer == [] + assert owner._evictions_since_compaction == 0 + if operation != "direct": + assert registry._slots["failed-cleanup"] is owner + assert registry._slots["failed-cleanup"] is not _TOMBSTONE + assert registry._id_locks == {} + assert await registry.get_or_create("failed-cleanup") is owner + with pytest.raises(EventStreamNotFoundError): + await owner.emit({"n": 2}) + with pytest.raises(EventStreamNotFoundError): + owner._compact_on_disk() + active = False + if operation == "direct": + owner._cleanup_locks() + else: + await registry.delete("failed-cleanup") + assert registry._slots["failed-cleanup"] is _TOMBSTONE + assert owner._file.closed + assert owner._lock_fd is None + assert owner._lock_path is None + await owner._on_delete() + assert list(tmp_path.iterdir()) == [] + + async def test_callback_error_cannot_retain_another_ids_cleanup_owner(self, tmp_path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + + monkeypatch.setattr(_concrete, "fcntl", None) + foreign = FileBackedReplayEventStream(path=tmp_path / "foreign.jsonl") + path = tmp_path / "current.jsonl" + path.write_text(json.dumps({"emit_time": 1, "payload": 1}) + "\n", encoding="utf-8") + primary = OSError("callback failed") + setattr(primary, "_replay_cleanup_owner", foreign) + setattr(primary, "_replay_cleanup_id", "current") + + def failed(payload): + raise primary + + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=failed) + try: + with pytest.raises(OSError) as caught: + await registry.get("current") + assert caught.value is primary + assert registry._slots == {} + assert registry._id_locks == {} + assert not foreign._file.closed + os.fstat(foreign._lock_fd) + assert foreign._lock_path.exists() + await registry.delete("current") + assert not path.exists() + finally: + await foreign._on_delete() + + async def test_failed_constructor_delete_reacquires_replacement_ownership(self, tmp_path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry, _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "constructor-owner.jsonl" + lock_path = path.with_suffix(".jsonl.lock") + path.write_text(json.dumps({"emit_time": 1, "payload": 1}) + "\n", encoding="utf-8") + unlink = Path.unlink + + def failed(payload): + raise ValueError("cursor failed") + + def denied(candidate, *args, **kwargs): + if candidate == lock_path: + raise PermissionError("lock removal denied") + return unlink(candidate, *args, **kwargs) + + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=failed) + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + with pytest.raises(PermissionError): + await registry.get("constructor-owner") + owner = registry._slots["constructor-owner"] + assert owner._write_failed + owner._cleanup_locks() + foreign = FileBackedReplayEventStream(path=path) + try: + with pytest.raises(RuntimeError, match="another process holds"): + await registry.delete("constructor-owner") + assert registry._slots["constructor-owner"] is owner + assert not foreign._file.closed + os.fstat(foreign._lock_fd) + assert lock_path.exists() + assert path.exists() + finally: + foreign._cleanup_locks() + await registry.delete("constructor-owner") + assert registry._slots["constructor-owner"] is _TOMBSTONE + assert registry._id_locks == {} + assert list(tmp_path.iterdir()) == [] + + class TestTTLPurgesDisk: async def test_ttl_eviction_removes_from_buffer(self, tmp_path: Path) -> None: """Rule 30 — TTL eviction removes expired records from the @@ -302,6 +555,339 @@ async def test_on_delete_unlinks_file(self, tmp_path: Path) -> None: assert not p.exists(), "file MUST be unlinked after _on_delete per rule 31" +class TestFileLifecycleOrdering: + @pytest.mark.parametrize("terminal", ["final", "non-final", "absent"]) + async def test_recovery_compaction_preserves_final_terminal_and_close_clock(self, tmp_path, monkeypatch, terminal): + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry, _TOMBSTONE + + path = tmp_path / "recovered-compaction.jsonl" + expired = [{"emit_time": 9000 + index / 1000, "payload": {"cursor": 10000 - index}} for index in range(1000)] + live = {"emit_time": 9995, "payload": {"cursor": 3}} + records = list(expired) + if terminal == "non-final": + records.append({"__terminal__": True}) + records.append(live) + if terminal == "final": + records.append({"__terminal__": True}) + path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") + monkeypatch.setattr(_concrete.time, "time", lambda: 10000) + cursors = [] + + def cursor(payload): + cursors.append(payload["cursor"]) + return payload["cursor"] + + # Windows cannot replace the open destination. Model a successful + # replacement so this test reaches the recovery compaction contract. + owners = [] + rehydrate = FileBackedReplayEventStream._rehydrate + replace = os.replace + + def observe(owner): + owners.append(owner) + rehydrate(owner) + + def commit(source, destination): + if os.name == "nt": + owners[-1]._file.close() + replace(source, destination) + + monkeypatch.setattr(FileBackedReplayEventStream, "_rehydrate", observe) + monkeypatch.setattr(_concrete.os, "replace", commit) + first = FileBackedReplayEventStream(path=path, cursor_fn=cursor, ttl_seconds=10) + try: + assert cursors == [record["payload"]["cursor"] for record in expired] + [3] + assert first._highest_cursor == 10000 + expected = [live] + ([{"__terminal__": True}] if terminal == "final" else []) + assert [json.loads(line) for line in path.read_text().splitlines()] == expected + assert len(first._buffer) == 1 + finally: + first._cleanup_locks() + + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=cursor, ttl_seconds=10) + restored = await restarted.get("recovered-compaction") + try: + assert await restored.last_cursor() == 3 + if terminal == "final": + assert restored._state == "CLOSED" + assert restored._close_time == 9995 + assert [event async for event in restored.subscribe()] == [{"cursor": 3}] + with pytest.raises(EventStreamClosedError): + await restored.emit({"cursor": 4}) + monkeypatch.setattr(_concrete.time, "time", lambda: 10005) + with pytest.raises(EventStreamNotFoundError): + await restarted.get("recovered-compaction") + assert restarted._slots["recovered-compaction"] is _TOMBSTONE + assert list(tmp_path.iterdir()) == [] + else: + assert restored._state == "ACTIVE" + await restored.emit({"cursor": 4}) + finally: + await restarted.delete("recovered-compaction") + + @pytest.mark.parametrize("backend", ["windows", "posix"]) + async def test_delete_keeps_writer_ownership_until_backing_is_removed(self, tmp_path, monkeypatch, backend): + from azure.ai.agentserver.core.streaming import _concrete + + held = set() + + def flock(descriptor, flags): + del flags + if held: + raise BlockingIOError("owned inode") + held.add(descriptor) + + monkeypatch.setattr( + _concrete, "fcntl", None if backend == "windows" else SimpleNamespace(LOCK_EX=1, LOCK_NB=2, flock=flock) + ) + path = tmp_path / "ownership.jsonl" + stream = FileBackedReplayEventStream(path=path) + original = stream._file + descriptor = original.fileno() + released = False + + class Handle: + @property + def closed(self): + return released + + def __getattr__(self, name): + return getattr(original, name) + + def close(self): + nonlocal released + if backend == "posix": + assert not path.exists(), "POSIX ownership must survive until unlink" + original.close() + held.discard(descriptor) + released = True + + stream._file = Handle() + unlink = Path.unlink + observations = [] + + def remove(candidate, *args, **kwargs): + if candidate == path: + observations.append("removing") + assert stream._file.closed is (backend == "windows") + if backend == "windows": + os.fstat(stream._lock_fd) + assert stream._lock_path.exists() + else: + assert held == {descriptor} + with pytest.raises(RuntimeError, match="another process holds"): + FileBackedReplayEventStream(path=path) + if backend == "posix" and os.name == "nt": + # Model POSIX unlink while keeping the mocked inode lock. + original.close() + result = unlink(candidate, *args, **kwargs) + if backend == "windows": + with pytest.raises(RuntimeError, match="another process holds"): + FileBackedReplayEventStream(path=path) + assert not path.exists(), "a lock loser must not recreate the deleted log" + return result + return unlink(candidate, *args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", remove) + await stream._on_delete() + finally: + stream._file = original + stream._cleanup_locks() + held.discard(descriptor) + assert observations == ["removing"] + assert list(tmp_path.iterdir()) == [] + successor = FileBackedReplayEventStream(path=path) + try: + await successor.emit({"n": "successor"}) + await stream._on_delete() + assert '"successor"' in path.read_text() + finally: + successor._cleanup_locks() + + @pytest.mark.skipif(not hasattr(os, "fork"), reason="inode-lock waiter regression requires POSIX") + @pytest.mark.parametrize("timing", ["before-unlink", "after-unlink", "after-release", "after-replacement"]) + async def test_cross_process_delayed_open_cannot_acknowledge_a_deleted_inode(self, tmp_path, monkeypatch, timing): + path = tmp_path / "delayed-writer.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": "owner"}) + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe() + process = context.Process(target=_delayed_replay_writer, args=(str(path), child)) + process.start() + child.close() + unlink = Path.unlink + cleanup = stream._cleanup_locks + results = [] + successor = None + + def attempt(): + parent.send("acquire") + assert parent.poll(10), "delayed writer did not finish" + results.append(parent.recv()) + + def remove(candidate, *args, **kwargs): + if candidate == path and timing == "before-unlink": + attempt() + result = unlink(candidate, *args, **kwargs) + if candidate == path and timing == "after-unlink": + attempt() + return result + + def release(): + nonlocal successor + cleanup() + if timing == "after-replacement": + successor = FileBackedReplayEventStream(path=path) + if timing in ("after-release", "after-replacement"): + attempt() + + try: + assert parent.poll(10), "delayed writer did not open the old inode" + assert parent.recv() == "opened" + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", remove) + patch.setattr(stream, "_cleanup_locks", release) + await stream._on_delete() + process.join(10) + assert process.exitcode == 0 + assert results == ["_ReplayFileNotFoundError" if timing == "after-release" else "RuntimeError"] + if successor is not None: + await successor.emit({"n": "successor"}) + await stream._on_delete() + assert '"successor"' in path.read_text() + assert '"waiter"' not in path.read_text() + else: + assert not path.exists() + assert list(tmp_path.iterdir()) == [] + finally: + if process.is_alive(): + process.terminate() + process.join(10) + parent.close() + process.close() + stream._cleanup_locks() + if successor is not None: + await successor._on_delete() + + @pytest.mark.parametrize("artifact", ["file", "log", "descriptor", "lock"]) + @pytest.mark.parametrize("failure", [PermissionError, asyncio.CancelledError]) + @pytest.mark.parametrize("write_failed", [False, True]) + async def test_windows_failed_delete_preserves_ownership_and_exact_cleanup_retry( + self, tmp_path, monkeypatch, artifact, failure, write_failed + ): + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry, _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + stream = await registry.get_or_create("interrupted-delete") + await stream.emit({"n": "owner"}) + stream._write_failed = write_failed + path = tmp_path / "interrupted-delete.jsonl" + lock_path = path.with_suffix(".jsonl.lock") + original = stream._file + primary = failure("owned cleanup interrupted") + active = True + unlink = Path.unlink + close_descriptor = os.close + + class Handle: + def __getattr__(self, name): + return getattr(original, name) + + def close(self): + if active and artifact == "file": + raise primary + original.close() + + stream._file = Handle() + descriptor = stream._lock_fd + + def remove(candidate, *args, **kwargs): + if (artifact == "log" and candidate == path) or (artifact == "lock" and candidate == lock_path): + raise primary + return unlink(candidate, *args, **kwargs) + + def close(candidate): + if artifact == "descriptor" and candidate == descriptor: + raise primary + close_descriptor(candidate) + + try: + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", remove) + patch.setattr(_concrete.os, "close", close) + for _ in range(2): + with pytest.raises(failure) as caught: + await registry.delete("interrupted-delete") + assert caught.value is primary + assert registry._slots["interrupted-delete"] is stream + assert registry._slots["interrupted-delete"] is not _TOMBSTONE + assert registry._id_locks == {} + assert path.exists() is (artifact in ("file", "log")) + assert lock_path.exists() + assert (stream._lock_fd is not None) is (artifact != "lock") + if stream._lock_fd is not None: + os.fstat(stream._lock_fd) + with pytest.raises(RuntimeError, match="another process holds"): + FileBackedReplayEventStream(path=path) + assert path.exists() is (artifact in ("file", "log")) + if artifact in ("descriptor", "lock"): + # Even an externally recreated path is not this owner's + # pending sidecar cleanup target. + path.write_text('{"emit_time": 1, "payload": "replacement"}\n', encoding="utf-8") + active = False + await registry.delete("interrupted-delete") + assert registry._slots["interrupted-delete"] is _TOMBSTONE + assert stream._file.closed + assert stream._lock_fd is None + assert stream._lock_path is None + if artifact in ("descriptor", "lock"): + assert path.read_text() == '{"emit_time": 1, "payload": "replacement"}\n' + else: + assert not path.exists() + finally: + active = False + stream._cleanup_locks() + + async def test_delete_rejects_changed_backing_then_reacquires_only_unowned_replacement(self, tmp_path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "changed-backing.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": "original"}) + stream._file.close() + replacement = tmp_path / "replacement.jsonl" + content = '{"emit_time": 1, "payload": "replacement"}\n' + replacement.write_text(content, encoding="utf-8") + os.replace(replacement, path) + try: + with pytest.raises(RuntimeError, match="backing file changed"): + await stream._on_delete() + assert stream._write_failed + assert path.read_text() == content + os.fstat(stream._lock_fd) + stream._cleanup_locks() + foreign = FileBackedReplayEventStream(path=path) + try: + with pytest.raises(RuntimeError, match="another process holds"): + await stream._on_delete() + assert path.read_text() == content + assert not foreign._file.closed + finally: + foreign._cleanup_locks() + await stream._on_delete() + assert list(tmp_path.iterdir()) == [] + finally: + stream._cleanup_locks() + + # ---------------------------------------------------------------- # Rule 32 — single-writer-per-path # ---------------------------------------------------------------- @@ -402,6 +988,484 @@ class TestCompactionPreservesPostCompactionWrites: inode and lost on the next process lifetime. """ + async def test_compaction_does_not_recreate_a_removed_replacement(self, tmp_path: Path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "removed-replacement.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + replace = _concrete.os.replace + + def remove_after_replace(source, destination): + # Release the old data handle so this interleaving also works on Windows. + stream._file.close() + replace(source, destination) + Path(destination).unlink() + + monkeypatch.setattr(_concrete.os, "replace", remove_after_replace) + try: + with pytest.raises(FileNotFoundError): + stream._compact_on_disk() + assert stream._write_failed + assert stream._file.closed + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + assert not path.exists() + assert not path.with_suffix(".jsonl.compact").exists() + finally: + await stream._on_delete() + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize("failure", [FileNotFoundError, asyncio.CancelledError, GeneratorExit]) + async def test_reopen_failure_retires_writer_and_wakes_waiting_subscriber( + self, tmp_path: Path, monkeypatch, failure + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "failed-reopen.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + iterator = stream.subscribe().__aiter__() + assert await iterator.__anext__() == {"n": 1} + waiting = asyncio.create_task(iterator.__anext__()) + await asyncio.sleep(0) + replace = _concrete.os.replace + open_file = open + primary = failure("replacement unavailable") + + def commit(source, destination): + stream._file.close() + replace(source, destination) + + def failed_open(candidate, mode): + if candidate == path and mode == "r+b": + raise primary + return open_file(candidate, mode) + + try: + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", failed_open, raising=False) + with pytest.raises(failure) as caught: + stream._compact_on_disk() + assert caught.value is primary + assert stream._file.closed + assert stream._retired_files == [] + assert stream._write_failed + before = path.read_bytes() + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.close() + with pytest.raises(RuntimeError, match="writer unavailable"): + stream.subscribe() + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.last_cursor() + with pytest.raises(RuntimeError, match="writer unavailable"): + await asyncio.wait_for(waiting, 2) + assert stream._subscriber_queues == [] + assert path.read_bytes() == before + assert [event.payload for event in stream._buffer] == [{"n": 1}] + finally: + if not waiting.done(): + waiting.cancel() + await asyncio.gather(waiting, return_exceptions=True) + await stream._on_delete() + + @pytest.mark.parametrize("stage", ["serialize", "replace"]) + async def test_pre_replacement_failure_preserves_valid_writer(self, tmp_path: Path, monkeypatch, stage) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "preparation-failed.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + original_handle = stream._file + + def failed(*args): + raise OSError("preparation failed") + + with monkeypatch.context() as patch: + if stage == "serialize": + patch.setattr(stream, "_serialize", failed) + else: + patch.setattr(_concrete.os, "replace", failed) + stream._compact_on_disk() + assert stream._file is original_handle + assert not original_handle.closed + assert not stream._write_failed + assert not path.with_suffix(".jsonl.compact").exists() + await stream.emit({"n": 2}, close=True) + stream._cleanup_locks() + restored = FileBackedReplayEventStream(path=path) + assert [event async for event in restored.subscribe()] == [{"n": 1}, {"n": 2}] + await restored._on_delete() + + async def test_failed_writer_deletion_never_removes_foreign_locked_replacement(self, tmp_path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "owned-replacement.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + replace = _concrete.os.replace + open_file = open + + def commit(source, destination): + stream._file.close() + replace(source, destination) + + def failed_open(candidate, mode): + if candidate == path and mode == "r+b": + raise PermissionError("reopen denied") + return open_file(candidate, mode) + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", failed_open, raising=False) + with pytest.raises(PermissionError): + stream._compact_on_disk() + foreign = FileBackedReplayEventStream(path=path) + try: + expected = path.read_bytes() + with pytest.raises(RuntimeError, match="another process holds"): + await stream._on_delete() + assert not foreign._file.closed + os.fstat(foreign._lock_fd) + assert foreign._lock_path.exists() + assert path.read_bytes() == expected + finally: + foreign._cleanup_locks() + await stream._on_delete() + assert list(tmp_path.iterdir()) == [] + + async def test_replacement_lock_failure_closes_new_handle_and_preserves_primary(self, tmp_path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + + calls = [] + primary = BlockingIOError("replacement lock denied") + + def flock(descriptor, flags): + calls.append(descriptor) + if len(calls) == 2: + raise primary + + monkeypatch.setattr(_concrete, "fcntl", SimpleNamespace(LOCK_EX=1, LOCK_NB=2, flock=flock)) + path = tmp_path / "lock-failed.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + replace = _concrete.os.replace + open_file = open + replacement_handles = [] + + def commit(source, destination): + stream._file.close() + replace(source, destination) + + def tracked_open(candidate, mode): + handle = open_file(candidate, mode) + if candidate == path and mode == "r+b": + replacement_handles.append(handle) + return handle + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", tracked_open, raising=False) + with pytest.raises(BlockingIOError) as caught: + stream._compact_on_disk() + assert caught.value is primary + assert replacement_handles[0].closed + assert stream._file.closed + assert stream._retired_files == [] + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + if os.name == "nt": + monkeypatch.setattr(_concrete, "fcntl", None) + await stream._on_delete() + assert list(tmp_path.iterdir()) == [] + + async def test_post_replacement_cleanup_failure_retains_handle_for_exact_retry(self, tmp_path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + + calls = [] + primary = BlockingIOError("replacement lock denied") + cleanup_error = PermissionError("replacement close denied") + active = True + + def flock(descriptor, flags): + calls.append(descriptor) + if len(calls) == 2: + raise primary + + monkeypatch.setattr(_concrete, "fcntl", SimpleNamespace(LOCK_EX=1, LOCK_NB=2, flock=flock)) + path = tmp_path / "close-failed.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + replace = _concrete.os.replace + open_file = open + replacement_handles = [] + + class FileHandle: + def __init__(self, handle): + self.handle = handle + + def __getattr__(self, name): + return getattr(self.handle, name) + + def close(self): + if active: + raise cleanup_error + self.handle.close() + + def commit(source, destination): + stream._file.close() + replace(source, destination) + + def tracked_open(candidate, mode): + handle = open_file(candidate, mode) + if candidate == path and mode == "r+b": + handle = FileHandle(handle) + replacement_handles.append(handle) + return handle + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", tracked_open, raising=False) + with pytest.raises(PermissionError) as caught: + stream._compact_on_disk() + assert caught.value is cleanup_error + assert caught.value.__context__ is primary + assert stream._file.closed + assert stream._retired_files == [replacement_handles[0]] + assert not replacement_handles[0].closed + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + active = False + if os.name == "nt": + monkeypatch.setattr(_concrete, "fcntl", None) + await stream._on_delete() + assert replacement_handles[0].closed + assert stream._retired_files == [] + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize("failure", [asyncio.CancelledError, GeneratorExit]) + async def test_pre_replacement_cancellation_removes_temp_but_keeps_writer(self, tmp_path, monkeypatch, failure): + path = tmp_path / "cancelled-preparation.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + primary = failure("preparation cancelled") + + def failed(*args): + raise primary + + with monkeypatch.context() as patch: + patch.setattr(stream, "_serialize", failed) + with pytest.raises(failure) as caught: + stream._compact_on_disk() + assert caught.value is primary + assert not stream._write_failed + assert not stream._file.closed + assert not path.with_suffix(".jsonl.compact").exists() + await stream.emit({"n": 2}, close=True) + assert [event async for event in stream.subscribe()] == [{"n": 1}, {"n": 2}] + await stream._on_delete() + + @pytest.mark.parametrize("stage", ["seek", "old-close"]) + async def test_post_replacement_seek_or_retirement_failure_closes_exact_handles(self, tmp_path, monkeypatch, stage): + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "retirement-failed.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + primary = OSError("replacement setup failed") + replace = _concrete.os.replace + open_file = open + replacements = [] + + class FileHandle: + def __init__(self, handle, old=False): + self.handle = handle + self.old = old + self.closed = False + self.close_calls = 0 + + def __getattr__(self, name): + return getattr(self.handle, name) + + def seek(self, *args): + if not self.old and stage == "seek": + raise primary + return self.handle.seek(*args) + + def close(self): + self.close_calls += 1 + if self.old and stage == "old-close" and self.close_calls == 1: + raise primary + self.handle.close() + self.closed = True + + old = FileHandle(stream._file, old=True) + stream._file = old + + def commit(source, destination): + old.handle.close() + replace(source, destination) + + def tracked_open(candidate, mode): + handle = open_file(candidate, mode) + if candidate == path and mode == "r+b": + handle = FileHandle(handle) + replacements.append(handle) + return handle + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", tracked_open, raising=False) + with pytest.raises(OSError) as caught: + stream._compact_on_disk() + assert caught.value is primary + assert old.closed + assert replacements[0].closed + assert stream._retired_files == [] + assert old.close_calls == (2 if stage == "old-close" else 1) + assert replacements[0].close_calls == 1 + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + await stream._on_delete() + assert list(tmp_path.iterdir()) == [] + + async def test_pre_replacement_temp_cleanup_failure_preserves_error_context(self, tmp_path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "temporary-cleanup.jsonl" + temporary = path.with_suffix(".jsonl.compact") + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + primary = OSError("replace denied") + cleanup_error = PermissionError("temporary removal denied") + unlink = Path.unlink + + def failed_replace(*args): + raise primary + + def denied_unlink(candidate, *args, **kwargs): + if candidate == temporary: + raise cleanup_error + return unlink(candidate, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", failed_replace) + patch.setattr(Path, "unlink", denied_unlink) + with pytest.raises(PermissionError) as caught: + stream._compact_on_disk() + assert caught.value is cleanup_error + assert caught.value.__cause__ is primary + assert not stream._write_failed + assert not stream._file.closed + temporary.unlink() + await stream.emit({"n": 2}, close=True) + await stream._on_delete() + + @pytest.mark.parametrize("disruption", ["cancel", "lock"]) + async def test_failed_writer_retains_deletion_owner_through_interrupted_cleanup( + self, tmp_path, monkeypatch, disruption + ): + from azure.ai.agentserver.core.streaming import _concrete + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "retry-retired.jsonl" + lock_path = path.with_suffix(".jsonl.lock") + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + replace = _concrete.os.replace + open_file = open + + def commit(source, destination): + stream._file.close() + replace(source, destination) + + def failed_open(candidate, mode): + if candidate == path and mode == "r+b": + raise PermissionError("reopen denied") + return open_file(candidate, mode) + + with monkeypatch.context() as patch: + patch.setattr(_concrete.os, "replace", commit) + patch.setattr(_concrete, "open", failed_open, raising=False) + with pytest.raises(PermissionError): + stream._compact_on_disk() + unlink = Path.unlink + entered = asyncio.Event() + release = asyncio.Event() + + def denied(candidate, *args, **kwargs): + if candidate == lock_path: + raise PermissionError("deletion lock removal denied") + return unlink(candidate, *args, **kwargs) + + async def paused(owner): + entered.set() + await release.wait() + + with monkeypatch.context() as patch: + if disruption == "lock": + patch.setattr(Path, "unlink", denied) + with pytest.raises(PermissionError): + await stream._on_delete() + owner = stream._deletion_owner + with pytest.raises(PermissionError): + await stream._on_delete() + assert stream._deletion_owner is owner + assert owner._lock_fd is None + else: + patch.setattr(_concrete._FileBackedReplayDeletion, "_on_delete", paused) + deleting = asyncio.create_task(stream._on_delete()) + try: + await asyncio.wait_for(entered.wait(), 2) + owner = stream._deletion_owner + deleting.cancel() + with pytest.raises(asyncio.CancelledError): + await deleting + assert stream._deletion_owner is owner + assert owner._lock_fd is not None + finally: + if not deleting.done(): + deleting.cancel() + release.set() + await asyncio.gather(deleting, return_exceptions=True) + assert path.exists() is (disruption != "lock") + assert lock_path.exists() + await stream._on_delete() + assert stream._deletion_owner is owner + assert owner._file.closed + assert owner._lock_fd is None + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.skipif(not hasattr(os, "fork"), reason="unlinked inode regression requires POSIX") + async def test_removed_replacement_never_acknowledges_writes_to_old_inode(self, tmp_path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "old-inode.jsonl" + stream = FileBackedReplayEventStream(path=path) + await stream.emit({"n": 1}) + old_handle = stream._file + old_descriptor = old_handle.fileno() + replace = _concrete.os.replace + + def remove(source, destination): + replace(source, destination) + Path(destination).unlink() + + monkeypatch.setattr(_concrete.os, "replace", remove) + with pytest.raises(FileNotFoundError): + stream._compact_on_disk() + assert old_handle.closed + with pytest.raises(OSError): + os.fstat(old_descriptor) + with pytest.raises(RuntimeError, match="writer unavailable"): + await stream.emit({"n": 2}) + assert not path.exists() + await stream._on_delete() + async def test_emit_after_compaction_persists_to_live_file(self, tmp_path: Path) -> None: p = tmp_path / "fb-compact.jsonl" s = FileBackedReplayEventStream(path=p, cursor_fn=lambda e: e["n"], ttl_seconds=600) diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_streams_registry.py b/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_streams_registry.py index 214a1b30a570..91c8fcc4bd2b 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_streams_registry.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/streaming/test_streams_registry.py @@ -15,10 +15,13 @@ import asyncio import inspect +import json import re from pathlib import Path +from types import SimpleNamespace import pytest +from anyio import CancelScope from azure.ai.agentserver.core.streaming import ( EventStream, @@ -31,11 +34,253 @@ FileBackedReplayEventStream, ReplayEventStream, ) +from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry pytestmark = pytest.mark.asyncio(loop_scope="function") +@pytest.mark.parametrize("backing", ["memory", "file"]) +async def test_many_missing_lookups_do_not_retain_lifecycle_locks(tmp_path: Path, backing: str) -> None: + registry = _StreamsRegistry() + if backing == "file": + registry.use_file_backed_replay(storage_dir=tmp_path) + for index in range(1000): + with pytest.raises(EventStreamNotFoundError): + await registry.get(f"missing-{index}") + assert registry._id_locks == {} + assert registry._slots == {} + assert list(tmp_path.iterdir()) == [] + + +async def test_lifecycle_locks_are_reclaimed_without_discarding_tombstones() -> None: + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + registry = _StreamsRegistry() + registry.use_in_memory_replay() + for index in range(100): + identifier = f"lifecycle-{index}" + original = await registry.get_or_create(identifier) + assert registry._id_locks == {} + assert await registry.get(identifier) is original + assert registry._id_locks == {} + await registry.delete(identifier) + assert registry._id_locks == {} + with pytest.raises(EventStreamNotFoundError): + await registry.get(identifier) + assert registry._slots[identifier] is _TOMBSTONE + assert registry._id_locks == {} + fresh = await registry.get_or_create(identifier) + assert fresh is not original + await registry.delete(identifier) + + +async def test_cancelled_delete_reclaims_its_lock_without_installing_a_tombstone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = _StreamsRegistry() + registry.use_in_memory_replay() + original = await registry.get_or_create("shared") + entered = asyncio.Event() + release = asyncio.Event() + on_delete = original._on_delete + + async def interrupted_delete() -> None: + entered.set() + await release.wait() + await on_delete() + + monkeypatch.setattr(original, "_on_delete", interrupted_delete) + deleting = asyncio.create_task(registry.delete("shared")) + await asyncio.wait_for(entered.wait(), 2) + deleting.cancel() + with pytest.raises(asyncio.CancelledError): + await deleting + assert registry._id_locks == {} + assert registry._slots["shared"] is original + monkeypatch.setattr(original, "_on_delete", on_delete) + await registry.delete("shared") + assert registry._id_locks == {} + fresh = await registry.get_or_create("shared") + assert fresh is not original + assert registry._id_locks == {} + + +@pytest.mark.parametrize("cancellation", ["task", "scope"]) +async def test_cancelled_waiter_never_splits_interleaved_create_get_delete_lock( + monkeypatch: pytest.MonkeyPatch, cancellation: str +) -> None: + registry = _StreamsRegistry() + registry.use_in_memory_replay() + original = await registry.get_or_create("shared") + entered = asyncio.Event() + release = asyncio.Event() + scope = CancelScope() + on_delete = original._on_delete + + async def paused_delete() -> None: + entered.set() + await release.wait() + await on_delete() + + monkeypatch.setattr(original, "_on_delete", paused_delete) + holder = asyncio.create_task(registry.delete("shared")) + await asyncio.wait_for(entered.wait(), 2) + entry = registry._id_locks["shared"] + factory = registry._factory + lookup = registry._load_existing + created = [] + + def create(identifier: str) -> EventStream: + assert registry._id_locks[identifier] is entry + stream = factory(identifier) + created.append(stream) + return stream + + def load(identifier: str, *, for_deletion: bool = False): + assert registry._id_locks[identifier] is entry + return lookup(identifier, for_deletion=for_deletion) + + monkeypatch.setattr(registry, "_factory", create) + monkeypatch.setattr(registry, "_load_existing", load) + + async def start(operation, *, cancelled: bool = False) -> asyncio.Task: + starting = asyncio.Event() + + async def run(): + if cancelled and cancellation == "scope": + with scope: + starting.set() + return await operation("shared") + return None + starting.set() + return await operation("shared") + + task = asyncio.create_task(run()) + await starting.wait() + return task + + pending = [] + try: + cancelled = await start(registry.get, cancelled=True) + pending.append(cancelled) + creating = await start(registry.get_or_create) + pending.append(creating) + getting = await start(registry.get) + pending.append(getting) + deleting = await start(registry.delete) + pending.append(deleting) + assert entry.users == 5 + if cancellation == "scope": + scope.cancel() + await asyncio.wait_for(cancelled, 2) + assert scope.cancelled_caught + else: + cancelled.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled + assert registry._id_locks["shared"] is entry + assert entry.users == 4 + release.set() + await asyncio.wait_for(holder, 2) + instance = await asyncio.wait_for(creating, 2) + assert await asyncio.wait_for(getting, 2) is instance + await asyncio.wait_for(deleting, 2) + assert created == [instance] + assert registry._id_locks == {} + finally: + release.set() + for task in pending: + if not task.done(): + task.cancel() + await asyncio.gather(holder, *pending, return_exceptions=True) + + +@pytest.mark.parametrize("cancellation", ["task", "scope"]) +async def test_cancelled_holder_keeps_waiters_on_the_exact_lock_until_last_release( + monkeypatch: pytest.MonkeyPatch, cancellation: str +) -> None: + registry = _StreamsRegistry() + registry.use_in_memory_replay() + original = await registry.get_or_create("shared") + first_entered = asyncio.Event() + second_entered = asyncio.Event() + first_release = asyncio.Event() + second_release = asyncio.Event() + scope = CancelScope() + active = 0 + calls = 0 + + async def inspect_stream(identifier: str, slot: EventStream) -> bool: + nonlocal active, calls + active += 1 + calls += 1 + assert active == 1 + try: + if calls == 1: + first_entered.set() + await first_release.wait() + elif calls == 2: + second_entered.set() + await second_release.wait() + return False + finally: + active -= 1 + + monkeypatch.setattr(registry, "_tombstone_if_close_clock_elapsed", inspect_stream) + + async def first(): + if cancellation == "scope": + with scope: + return await registry.get("shared") + return None + return await registry.get("shared") + + holder = asyncio.create_task(first()) + await asyncio.wait_for(first_entered.wait(), 2) + entry = registry._id_locks["shared"] + starting = asyncio.Event() + + async def next_get(): + starting.set() + return await registry.get("shared") + + waiter = asyncio.create_task(next_get()) + await starting.wait() + assert entry.users == 2 + tasks = [holder, waiter] + try: + if cancellation == "scope": + scope.cancel() + await asyncio.wait_for(holder, 2) + assert scope.cancelled_caught + else: + holder.cancel() + with pytest.raises(asyncio.CancelledError): + await holder + await asyncio.wait_for(second_entered.wait(), 2) + assert registry._id_locks["shared"] is entry + assert entry.users == 1 + starting.clear() + last = asyncio.create_task(next_get()) + tasks.append(last) + await starting.wait() + assert registry._id_locks["shared"] is entry + assert entry.users == 2 + second_release.set() + assert await waiter is original + assert await last is original + assert registry._id_locks == {} + assert active == 0 + finally: + first_release.set() + second_release.set() + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + # ---------------------------------------------------------------- # Per-test fixture — snapshot + restore registry private state # (streaming.md §7.6 — no public reset()). @@ -48,6 +293,7 @@ def _reset_registry(): saved_slots = dict(streams._slots) # type: ignore[attr-defined] saved_locks = dict(streams._id_locks) # type: ignore[attr-defined] saved_factory = streams._factory # type: ignore[attr-defined] + saved_restore = streams._restore streams._slots.clear() # type: ignore[attr-defined] streams._id_locks.clear() # type: ignore[attr-defined] streams.use_in_memory_live() # default backing @@ -57,6 +303,7 @@ def _reset_registry(): streams._id_locks.clear() # type: ignore[attr-defined] streams._id_locks.update(saved_locks) # type: ignore[attr-defined] streams._factory = saved_factory # type: ignore[attr-defined] + streams._restore = saved_restore # ---------------------------------------------------------------- @@ -289,6 +536,565 @@ async def test_10_concurrent_get_or_create_returns_same_instance( assert r is first, "concurrent get_or_create MUST be atomic" +class TestPersistedLookup: + @pytest.mark.parametrize( + "invalid_record", + [b"not json\n", b"\xff\n", b'{"payload": {"n": 2}}\n'], + ids=["malformed-json", "invalid-encoding", "missing-time"], + ) + async def test_cold_delete_removes_corrupt_log_and_allows_recreation( + self, tmp_path: Path, invalid_record: bytes + ) -> None: + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + original_registry = _StreamsRegistry() + original_registry.use_file_backed_replay(storage_dir=tmp_path) + original = await original_registry.get_or_create("corrupt") + await original.emit({"n": 1}) + await original.emit({"n": 3}) + await original.close() + original._cleanup_locks() + path = tmp_path / "corrupt.jsonl" + records = path.read_bytes().splitlines(keepends=True) + path.write_bytes(records[0] + invalid_record + b"".join(records[1:])) + + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + with pytest.raises(RuntimeError, match="malformed|missing 'emit_time'"): + await restarted.get("corrupt") + assert restarted._slots == {} + assert restarted._id_locks == {} + await restarted.delete("corrupt") + assert list(tmp_path.iterdir()) == [] + assert restarted._slots["corrupt"] is _TOMBSTONE + assert restarted._id_locks == {} + with pytest.raises(EventStreamNotFoundError): + await restarted.get("corrupt") + fresh = await restarted.get_or_create("corrupt") + await fresh.emit({"n": 4}, close=True) + assert [event async for event in fresh.subscribe()] == [{"n": 4}] + await restarted.delete("corrupt") + + async def test_valid_cold_delete_never_rehydrates_or_calls_payload_callbacks( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + original = await registry.get_or_create("valid") + await original.emit({"n": 1}, close=True) + original._cleanup_locks() + + def forbidden(*args, **kwargs): + raise AssertionError("cold deletion must not read replay events") + + restarted = _StreamsRegistry() + restarted.use_file_backed_replay( + storage_dir=tmp_path, + cursor_fn=forbidden, + serializer=forbidden, + deserializer=forbidden, + ) + monkeypatch.setattr(FileBackedReplayEventStream, "_rehydrate", forbidden) + await restarted.delete("valid") + assert list(tmp_path.iterdir()) == [] + assert restarted._id_locks == {} + + @pytest.mark.parametrize("windows", [False, True]) + async def test_cold_delete_lock_contention_preserves_log_and_allows_retry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, windows: bool + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + if windows: + monkeypatch.setattr(_concrete, "fcntl", None) + writer = _StreamsRegistry() + writer.use_file_backed_replay(storage_dir=tmp_path) + original = await writer.get_or_create("locked") + await original.emit({"n": 1}) + path = tmp_path / "locked.jsonl" + expected = path.read_bytes() + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + try: + for _ in range(2): + with pytest.raises(RuntimeError, match="another process holds"): + await restarted.delete("locked") + assert path.read_bytes() == expected + assert restarted._slots == {} + assert restarted._id_locks == {} + finally: + original._cleanup_locks() + await restarted.delete("locked") + assert list(tmp_path.iterdir()) == [] + assert restarted._id_locks == {} + + @pytest.mark.parametrize("artifact", ["log", "lock", "descriptor"]) + async def test_corrupt_cold_delete_cleanup_failure_keeps_exact_owner_for_retry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, artifact: str + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "retry.jsonl" + path.write_bytes(b'{"emit_time": 1, "payload": 1}\nnot json\n') + lock_path = path.with_suffix(".jsonl.lock") + denied_path = path if artifact == "log" else lock_path + unlink = Path.unlink + + def denied(candidate: Path, *args, **kwargs): + if candidate == denied_path: + raise PermissionError("artifact removal denied") + return unlink(candidate, *args, **kwargs) + + def denied_close(descriptor: int): + raise PermissionError("artifact removal denied") + + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + if artifact == "descriptor": + patch.setattr(_concrete.os, "close", denied_close) + with pytest.raises(PermissionError, match="artifact removal denied"): + await restarted.delete("retry") + owner = restarted._slots["retry"] + descriptor = owner._lock_fd + assert owner is not _TOMBSTONE + for _ in range(2): + with pytest.raises(PermissionError, match="artifact removal denied"): + await restarted.delete("retry") + assert restarted._slots["retry"] is owner + assert restarted._id_locks == {} + assert path.exists() is (artifact == "log") + assert lock_path.exists() + assert owner._file.closed + assert owner._lock_fd == descriptor + assert (owner._lock_fd is not None) is (artifact != "lock") + assert await restarted.get_or_create("retry") is owner + with pytest.raises(EventStreamNotFoundError): + await owner.emit({"n": 2}) + + await restarted.delete("retry") + assert list(tmp_path.iterdir()) == [] + assert restarted._slots["retry"] is _TOMBSTONE + assert restarted._id_locks == {} + fresh = await restarted.get_or_create("retry") + await fresh.emit({"n": 2}, close=True) + assert [event async for event in fresh.subscribe()] == [{"n": 2}] + await restarted.delete("retry") + + @pytest.mark.parametrize("windows", [False, True]) + @pytest.mark.parametrize("failure", [PermissionError, FileNotFoundError]) + @pytest.mark.parametrize("operation", ["get", "delete", "get_or_create"]) + async def test_cold_access_lock_acquisition_errors_propagate_and_close_handle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, windows: bool, failure, operation: str + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + path = tmp_path / "restricted.jsonl" + expected = b"not json\n" + path.write_bytes(expected) + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + opened = [] + open_file = open + + def tracked_open(*args, **kwargs): + handle = open_file(*args, **kwargs) + opened.append(handle) + return handle + + def denied(*args, **kwargs): + raise failure("lock acquisition denied") + + with monkeypatch.context() as patch: + patch.setattr(_concrete, "open", tracked_open, raising=False) + if windows: + patch.setattr(_concrete, "fcntl", None) + patch.setattr(_concrete.os, "open", denied) + else: + patch.setattr(_concrete, "fcntl", SimpleNamespace(LOCK_EX=1, LOCK_NB=2, flock=denied)) + with pytest.raises(failure, match="lock acquisition denied"): + await getattr(registry, operation)("restricted") + assert len(opened) == (0 if windows else 1) + assert all(handle.closed for handle in opened) + assert registry._slots == {} + assert registry._id_locks == {} + assert path.read_bytes() == expected + assert sorted(tmp_path.iterdir()) == [path] + + await registry.delete("restricted") + assert list(tmp_path.iterdir()) == [] + + async def test_cold_lookup_file_disappearing_before_open_does_not_create_or_retain_a_log( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + original = await registry.get_or_create("disappearing") + await original.emit({"n": 1}, close=True) + original._cleanup_locks() + path = tmp_path / "disappearing.jsonl" + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + open_file = open + modes = [] + + def remove_before_open(candidate, mode): + assert candidate == path + assert candidate.stat().st_size > 0 + candidate.unlink() + modes.append(mode) + return open_file(candidate, mode) + + with monkeypatch.context() as patch: + patch.setattr(_concrete, "open", remove_before_open, raising=False) + with pytest.raises(EventStreamNotFoundError): + await restarted.get("disappearing") + assert modes == ["r+b"] + assert restarted._slots == {} + assert restarted._id_locks == {} + assert list(tmp_path.iterdir()) == [] + with pytest.raises(EventStreamNotFoundError): + await restarted.get("disappearing") + assert restarted._slots == {} + assert restarted._id_locks == {} + fresh = await restarted.get_or_create("disappearing") + assert path.exists() + await fresh.emit({"n": 2}, close=True) + assert [event async for event in fresh.subscribe()] == [{"n": 2}] + await restarted.delete("disappearing") + + @pytest.mark.parametrize("operation", ["get", "get_or_create"]) + @pytest.mark.parametrize("custom_payload", [False, True]) + async def test_cold_lookup_and_creation_preserve_recovery_and_append_semantics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, operation: str, custom_payload: bool + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + def serialize(payload): + return json.dumps({"value": payload}).encode("utf-8") + + def deserialize(payload): + return json.loads(payload)["value"] + + configuration = { + "storage_dir": tmp_path, + "cursor_fn": lambda event: event["n"], + "serializer": serialize if custom_payload else None, + "deserializer": deserialize if custom_payload else None, + } + registry = _StreamsRegistry() + registry.use_file_backed_replay(**configuration) + original = await registry.get_or_create("retained") + await original.emit({"n": 1}) + original._cleanup_locks() + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(**configuration) + open_file = open + modes = [] + + def track_open(candidate, mode): + modes.append(mode) + return open_file(candidate, mode) + + with monkeypatch.context() as patch: + patch.setattr(_concrete, "open", track_open, raising=False) + restored = await getattr(restarted, operation)("retained") + assert modes == (["r+b"] if operation == "get" else ["a+b"]) + assert await restored.last_cursor() == 1 + await restored.emit({"n": 2}, close=True) + assert [event async for event in restored.subscribe()] == [{"n": 1}, {"n": 2}] + restored._cleanup_locks() + final_registry = _StreamsRegistry() + final_registry.use_file_backed_replay(**configuration) + final = await final_registry.get("retained") + assert await final.last_cursor() == 2 + assert [event async for event in final.subscribe()] == [{"n": 1}, {"n": 2}] + await final_registry.delete("retained") + assert list(tmp_path.iterdir()) == [] + + async def test_cold_delete_file_disappearing_before_open_never_creates_a_log( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + path = tmp_path / "disappearing.jsonl" + path.write_bytes(b"not json\n") + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + open_file = open + modes = [] + + def remove_before_open(candidate, mode): + assert candidate == path + modes.append(mode) + candidate.unlink() + return open_file(candidate, mode) + + monkeypatch.setattr(_concrete, "open", remove_before_open, raising=False) + await registry.delete("disappearing") + assert modes == ["r+b"] + assert list(tmp_path.iterdir()) == [] + assert registry._slots["disappearing"] is _TOMBSTONE + assert registry._id_locks == {} + + @pytest.mark.parametrize("cancellation", ["task", "scope"]) + async def test_cancelled_cold_delete_retains_cleanup_owner_until_retry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cancellation: str + ) -> None: + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + path = tmp_path / "cancelled.jsonl" + path.write_bytes(b"not json\n") + registry = _StreamsRegistry() + registry.use_file_backed_replay(storage_dir=tmp_path) + entered = asyncio.Event() + release = asyncio.Event() + scope = CancelScope() + owners = [] + + async def paused_delete(owner): + owners.append(owner) + entered.set() + await release.wait() + + async def delete(): + if cancellation == "scope": + with scope: + await registry.delete("cancelled") + else: + await registry.delete("cancelled") + + with monkeypatch.context() as patch: + patch.setattr(FileBackedReplayEventStream, "_on_delete", paused_delete) + deleting = asyncio.create_task(delete()) + try: + await asyncio.wait_for(entered.wait(), 2) + owner = owners[0] + assert registry._slots["cancelled"] is owner + assert registry._id_locks["cancelled"].users == 1 + if cancellation == "scope": + scope.cancel() + await asyncio.wait_for(deleting, 2) + assert scope.cancelled_caught + else: + deleting.cancel() + with pytest.raises(asyncio.CancelledError): + await deleting + assert registry._slots["cancelled"] is owner + assert registry._slots["cancelled"] is not _TOMBSTONE + assert not owner._file.closed + assert owner._lock_fd is not None + assert path.exists() + assert registry._id_locks == {} + finally: + if not deleting.done(): + deleting.cancel() + await asyncio.gather(deleting, return_exceptions=True) + release.set() + + await registry.delete("cancelled") + assert list(tmp_path.iterdir()) == [] + assert registry._slots["cancelled"] is _TOMBSTONE + assert registry._id_locks == {} + fresh = await registry.get_or_create("cancelled") + await fresh.emit({"n": 1}, close=True) + assert [event async for event in fresh.subscribe()] == [{"n": 1}] + await registry.delete("cancelled") + + async def test_windows_lock_file_removal_failure_is_retryable(self, tmp_path: Path, monkeypatch): + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _TOMBSTONE + + monkeypatch.setattr(_concrete, "fcntl", None) + streams.use_file_backed_replay(storage_dir=tmp_path) + original = await streams.get_or_create("windows-retry") + await original.emit({"n": 1}) + await original.close() + lock_path = tmp_path / "windows-retry.jsonl.lock" + unlink = Path.unlink + + def denied(path, *args, **kwargs): + if path == lock_path: + raise PermissionError("lock removal denied") + return unlink(path, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + for _ in range(2): + with pytest.raises(PermissionError, match="lock removal denied"): + await streams.delete("windows-retry") + assert streams._slots["windows-retry"] is not _TOMBSTONE + assert original._lock_fd is None + assert lock_path.exists() + assert not (tmp_path / "windows-retry.jsonl").exists() + + await streams.delete("windows-retry") + assert list(tmp_path.iterdir()) == [] + fresh = await streams.get_or_create("windows-retry") + await fresh.emit({"n": 2}) + await fresh.close() + assert [event async for event in fresh.subscribe()] == [{"n": 2}] + await streams.delete("windows-retry") + + @pytest.mark.parametrize("expired", [False, True]) + async def test_unlink_failure_preserves_slot_until_cleanup_retry(self, tmp_path: Path, monkeypatch, expired): + from azure.ai.agentserver.core.streaming import _concrete + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry, _TOMBSTONE + + monkeypatch.setattr(_concrete.time, "time", lambda: 1000.0) + streams.use_file_backed_replay(storage_dir=tmp_path, ttl_seconds=60) + original = await streams.get_or_create("retry-delete") + await original.emit({"n": 1}) + await original.close() + original._cleanup_locks() + streams._slots.clear() + if expired: + monkeypatch.setattr(_concrete.time, "time", lambda: 1061.0) + + unlink = Path.unlink + log_path = tmp_path / "retry-delete.jsonl" + + def denied(path, *args, **kwargs): + if path == log_path: + raise PermissionError("replay removal denied") + return unlink(path, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + with pytest.raises(PermissionError, match="replay removal denied"): + if expired: + await streams.get("retry-delete") + else: + await streams.delete("retry-delete") + assert log_path.exists() + assert streams._slots["retry-delete"] is not _TOMBSTONE + existing = await streams.get_or_create("retry-delete") + with pytest.raises(EventStreamNotFoundError): + await existing.emit({"n": 2}) + with pytest.raises(PermissionError, match="replay removal denied"): + await streams.get("retry-delete") + + await streams.delete("retry-delete") + assert not log_path.exists() + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + with pytest.raises(EventStreamNotFoundError): + await restarted.get("retry-delete") + fresh = await streams.get_or_create("retry-delete") + await fresh.emit({"n": 2}) + await fresh.close() + assert [event async for event in fresh.subscribe()] == [{"n": 2}] + await streams.delete("retry-delete") + + async def test_missing_lookup_and_delete_do_not_create_files(self, tmp_path: Path) -> None: + streams.use_file_backed_replay(storage_dir=tmp_path) + with pytest.raises(EventStreamNotFoundError): + await streams.get("missing") + await streams.delete("missing") + assert list(tmp_path.iterdir()) == [] + + async def test_concurrent_lookup_restores_one_instance(self, tmp_path: Path) -> None: + streams.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=lambda event: event["n"]) + original = await streams.get_or_create("retained") + await original.emit({"n": 1}) + await original.close() + original._cleanup_locks() + streams._slots.clear() + + restored = await asyncio.gather( + *(streams.get("retained") for _ in range(10)), + streams.get_or_create("retained"), + ) + assert all(stream is restored[0] for stream in restored) + assert [event async for event in restored[0].subscribe()] == [{"n": 1}] + await streams.delete("retained") + + async def test_cold_delete_removes_persisted_log(self, tmp_path: Path) -> None: + streams.use_file_backed_replay(storage_dir=tmp_path) + original = await streams.get_or_create("retained") + await original.emit({"n": 1}) + await original.close() + original._cleanup_locks() + streams._slots.clear() + + await streams.delete("retained") + assert list(tmp_path.iterdir()) == [] + with pytest.raises(EventStreamNotFoundError): + await streams.get("retained") + + async def test_expired_cold_lookup_cleans_up_and_allows_fresh_creation(self, tmp_path: Path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + monkeypatch.setattr(_concrete.time, "time", lambda: 1000.0) + streams.use_file_backed_replay(storage_dir=tmp_path, ttl_seconds=60) + original = await streams.get_or_create("expired") + await original.emit({"n": 1}) + await original.close() + original._cleanup_locks() + streams._slots.clear() + monkeypatch.setattr(_concrete.time, "time", lambda: 1061.0) + + with pytest.raises(EventStreamNotFoundError): + await streams.get("expired") + assert list(tmp_path.iterdir()) == [] + fresh = await streams.get_or_create("expired") + assert fresh is not original + await fresh.emit({"n": 2}) + await fresh.close() + assert [event async for event in fresh.subscribe()] == [{"n": 2}] + await streams.delete("expired") + + async def test_locked_persisted_log_is_not_reported_missing(self, tmp_path: Path) -> None: + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + + streams.use_file_backed_replay(storage_dir=tmp_path) + original = await streams.get_or_create("locked") + await original.emit({"n": 1}) + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path) + try: + with pytest.raises(RuntimeError, match="another process holds"): + await restarted.get("locked") + finally: + await streams.delete("locked") + + async def test_lookup_permission_failure_is_not_reported_missing(self, tmp_path: Path, monkeypatch) -> None: + from azure.ai.agentserver.core.streaming import _concrete + + streams.use_file_backed_replay(storage_dir=tmp_path) + + def denied(path, *args, **kwargs): + raise PermissionError("replay access denied") + + with monkeypatch.context() as patch: + patch.setattr(_concrete, "open", denied, raising=False) + with pytest.raises(PermissionError, match="replay access denied"): + await streams.get("restricted") + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize("backing", ["use_in_memory_live", "use_in_memory_replay"]) + async def test_switching_to_memory_disables_disk_lookup(self, tmp_path: Path, backing: str) -> None: + streams.use_file_backed_replay(storage_dir=tmp_path) + original = await streams.get_or_create("retained") + await original.emit({"n": 1}) + await original.close() + original._cleanup_locks() + streams._slots.clear() + getattr(streams, backing)() + with pytest.raises(EventStreamNotFoundError): + await streams.get("retained") + streams.use_file_backed_replay(storage_dir=tmp_path) + await streams.delete("retained") + + # ---------------------------------------------------------------- # — third-party-impl invariant # ---------------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_hosted_provider_transport.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_hosted_provider_transport.py index ef054a206093..1f75c5187771 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_hosted_provider_transport.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_hosted_provider_transport.py @@ -421,6 +421,27 @@ class TestLocalProviderOpaqueCursorRoundTrip: """B-Hosted-2: hosted provider round-trips the service's opaque continuation cursor without parsing it.""" + @pytest.mark.asyncio + @pytest.mark.parametrize("cursor", [None, "", "opaque-page"]) + @pytest.mark.parametrize("limit", [None, 20]) + async def test_incomplete_continuation_raises_instead_of_returning_partial_tasks( + self, cursor: str | None, limit: int | None + ) -> None: + first = { + "data": [{"id": "t1", "agent_name": "a", "session_id": "s"}], + "has_more": True, + "last_id": "opaque-page", + } + second = {"data": [{"id": "t2", "agent_name": "a", "session_id": "s"}], "has_more": True, "last_id": cursor} + provider = _make_provider( + FakeAsyncHttpTransport([FakeResponse.json_response(first), FakeResponse.json_response(second)]) + ) + try: + with pytest.raises(ValueError, match="continuation cursor"): + await provider.list(agent_name="a", session_id="s", limit=limit) + finally: + await provider.close() + @pytest.mark.asyncio async def test_list_cursor_passed_back_verbatim(self) -> None: """C-PRV-12: opaque cursor from service is passed back as `after` diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_local_provider.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_local_provider.py index 70e48783d124..1c338047b9c9 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_local_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_local_provider.py @@ -101,6 +101,35 @@ async def test_delete_task(self, provider: LocalFileTaskProvider, sample_create_ class TestLocalProviderListing: """Tests for listing/querying tasks.""" + @pytest.mark.asyncio + async def test_no_limit_enumerates_all_tasks_without_changing_explicit_pages( + self, provider: LocalFileTaskProvider + ) -> None: + expected = set() + for index in range(126): + info = await provider.create( + TaskCreateRequest( + id=f"task-{index}", + agent_name="agent", + session_id="session", + title="Listing regression", + tags={"task_name": "response"}, + ) + ) + expected.add(info.id) + await provider.create(TaskCreateRequest(agent_name="other", session_id="session", title="Other agent")) + await provider.create(TaskCreateRequest(agent_name="agent", session_id="other", title="Other session")) + filters = {"agent_name": "agent", "session_id": "session", "tag": {"task_name": "response"}} + all_tasks = await provider.list(**filters) + assert len(all_tasks) == 126 + assert {info.id for info in all_tasks} == expected + first = await provider.list(**filters, limit=20) + assert len(first) == 20 + remaining = await provider.list(**filters, after=first[-1].id) + assert len(remaining) == 106 + assert [info.id for info in first + remaining] == [info.id for info in all_tasks] + assert len(await provider.list(**filters, limit=1000)) == 100 + @pytest.mark.asyncio async def test_list_tasks_by_agent(self, provider: LocalFileTaskProvider) -> None: """list filters by agent_name and session_id.""" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_provider_listing_limits.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_provider_listing_limits.py new file mode 100644 index 000000000000..93f8af7fd992 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_provider_listing_limits.py @@ -0,0 +1,210 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Parity for total task-list limits and complete ownership enumeration.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +import pytest +import pytest_asyncio + +from azure.ai.agentserver.core.tasks._exceptions_internal import _HostedConflict +from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider +from azure.ai.agentserver.core.tasks._manager import TaskManager +from azure.ai.agentserver.core.tasks._models import TaskCreateRequest, TaskInfo + +from .conftest import FakeAsyncHttpTransport, FakeResponse +from .test_hosted_provider_transport import _make_provider + + +@pytest_asyncio.fixture(params=["local", "hosted"]) +async def listing(request, tmp_path, monkeypatch): + filters = {"agent_name": "agent", "session_id": "session", "tag": {"kind": "matching"}, "order": "asc"} + records = [] + if request.param == "local": + provider = LocalFileTaskProvider(base_dir=tmp_path) + for index in range(126): + records.append( + await provider.create( + TaskCreateRequest( + id=f"task-{index:03}", + agent_name="agent", + session_id="session", + title="Listing regression", + tags={"kind": "matching"}, + ) + ) + ) + records.sort(key=lambda info: info.created_at or "") + await provider.create( + TaskCreateRequest(agent_name="agent", session_id="session", title="Filtered out", tags={"kind": "other"}) + ) + yield SimpleNamespace(provider=provider, records=records, filters=filters, requests=[], kind="local") + return + + records = [ + TaskInfo.from_dict( + {"id": f"task-{index:03}", "agent_name": "agent", "session_id": "session", "status": "pending"} + ) + for index in range(126) + ] + provider = _make_provider(FakeAsyncHttpTransport()) + queries = [] + offsets = {"opaque-start": 6} + + async def send(http_request): + query = parse_qs(urlparse(http_request.url).query) + queries.append(query) + assert query["agent_name"] == ["agent"] + assert query["session_id"] == ["session"] + assert query["tag.kind"] == ["matching"] + assert query["order"] == ["asc"] + cursor = query.get("after", [None])[0] + offset = offsets[cursor] if cursor is not None else 0 + end = min(offset + min(7, int(query["limit"][0])), len(records)) + body = {"data": [record.to_dict() for record in records[offset:end]], "has_more": end < len(records)} + if body["has_more"]: + token = f"opaque-page-{end}" + offsets[token] = end + body["last_id"] = token + return SimpleNamespace(status_code=200, headers={}, body=lambda: json.dumps(body).encode()) + + monkeypatch.setattr(provider, "_send", send) + try: + yield SimpleNamespace(provider=provider, records=records, filters=filters, requests=queries, kind="hosted") + finally: + await provider.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", ["omitted", None, 20, 25, 100, 101, 1000]) +@pytest.mark.parametrize("continued", [False, True]) +async def test_listing_total_limit_and_unbounded_result_shape(listing, limit, continued): + kwargs = {} if limit == "omitted" else {"limit": limit} + if continued: + kwargs["after"] = listing.records[5].id if listing.kind == "local" else "opaque-start" + expected = listing.records[6:] if continued else listing.records + bounded = limit not in ("omitted", None) + if bounded: + expected = expected[: min(limit, 100)] + result = await listing.provider.list(**listing.filters, **kwargs) + assert isinstance(result, list) + assert all(isinstance(info, TaskInfo) for info in result) + assert len(result) == (min(limit, 100) if bounded else 120 if continued else 126) + assert [info.id for info in result] == [info.id for info in expected] + if listing.kind == "hosted": + assert len(listing.requests) == (len(expected) + 6) // 7 + assert listing.requests[0]["limit"] == [str(min(limit, 100) if bounded else 100)] + for index, query in enumerate(listing.requests): + assert query["limit"] == [str(min(limit, 100) - index * 7 if bounded else 100)] + if index: + assert query["after"][0].startswith("opaque-page-") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", [0, -1]) +async def test_nonpositive_total_limit_has_matching_error_classification(listing, limit): + with pytest.raises(_HostedConflict) as raised: + await listing.provider.list(**listing.filters, limit=limit) + assert raised.value._code == "invalid_request" + assert raised.value.status_code == 400 + assert listing.requests == [] + + +@pytest.mark.asyncio +async def test_hosted_budget_stops_without_fetching_or_exposing_a_continuation(): + records = [{"id": f"task-{index}", "agent_name": "agent", "session_id": "session"} for index in range(30)] + transport = FakeAsyncHttpTransport([FakeResponse.json_response({"data": records, "has_more": True})]) + provider = _make_provider(transport) + try: + result = await provider.list(limit=20) + assert isinstance(result, list) + assert all(isinstance(info, TaskInfo) for info in result) + assert [info.id for info in result] == [f"task-{index}" for index in range(20)] + assert len(transport.requests) == 1 + finally: + await provider.close() + + +@pytest.mark.asyncio +async def test_manager_ownership_and_recovery_scans_explicitly_override_a_bounded_provider_default(tmp_path): + provider = LocalFileTaskProvider(base_dir=tmp_path) + for index in range(126): + await provider.create( + TaskCreateRequest( + id=f"task-{index:03}", + agent_name="agent", + session_id="session", + title="Ownership listing", + tags={"task_name": "response"}, + source=TaskManager._build_source("response"), + ) + ) + limits = [] + + async def legacy_list(*, limit=20, **kwargs): + limits.append(limit) + return await provider.list(limit=limit, **kwargs) + + config = SimpleNamespace(agent_name="agent", session_id="session", agent_version="1.0.0", is_hosted=False) + manager = TaskManager(config=config, provider=SimpleNamespace(list=legacy_list)) + result = await manager.list_tasks(fn_name="response") + assert isinstance(result, list) + assert all(isinstance(info, TaskInfo) for info in result) + assert len(result) == 126 + assert {info.id for info in result} == {f"task-{index:03}" for index in range(126)} + assert limits == [None] + await manager._recover_stale_tasks() + assert limits == [None, None] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit,expected", [(None, 35), (25, 25)]) +async def test_hosted_empty_page_continues_without_consuming_result_budget(limit, expected): + records = [{"id": f"task-{index}", "agent_name": "agent", "session_id": "session"} for index in range(35)] + transport = FakeAsyncHttpTransport( + [ + FakeResponse.json_response({"data": [], "has_more": True, "last_id": "opaque-empty"}), + FakeResponse.json_response({"data": records, "has_more": False}), + ] + ) + provider = _make_provider(transport) + try: + result = await provider.list(limit=limit) + assert len(result) == expected + assert [info.id for info in result] == [f"task-{index}" for index in range(expected)] + assert len(transport.requests) == 2 + assert parse_qs(urlparse(transport.requests[1].url).query)["after"] == ["opaque-empty"] + finally: + await provider.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", [None, 25]) +async def test_hosted_later_page_error_never_returns_partial_success(limit): + transport = FakeAsyncHttpTransport( + [ + FakeResponse.json_response( + { + "data": [{"id": "first", "agent_name": "agent", "session_id": "session"}], + "has_more": True, + "last_id": "opaque-next", + } + ), + FakeResponse.json_response( + {"error": {"code": "invalid_request", "message": "listing unavailable"}}, status_code=400 + ), + ] + ) + provider = _make_provider(transport) + try: + with pytest.raises(_HostedConflict) as raised: + await provider.list(limit=limit) + assert raised.value._code == "invalid_request" + assert len(transport.requests) == 2 + finally: + await provider.close() diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_filter.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_filter.py index 6470ecc8fb2d..3c0b533d5dd1 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_filter.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_filter.py @@ -14,6 +14,7 @@ from __future__ import annotations +import asyncio import datetime from pathlib import Path @@ -164,3 +165,84 @@ async def my_task(ctx: TaskContext[str]) -> str: mgr_mod._manager = None # Silence unused-arg warning on the helper. _ = foreign_record + + +@pytest.mark.parametrize("recovery_path", ["startup", "periodic", "inline"]) +@pytest.mark.asyncio +async def test_disabled_recovery_does_not_mutate_task( + captured_local, monkeypatch: pytest.MonkeyPatch, recovery_path: str +) -> None: + from azure.ai.agentserver.core.tasks import _decorator + from azure.ai.agentserver.core.tasks._manager import _SCHEMA_VERSION, _SCHEMA_VERSION_KEY + + monkeypatch.setattr(_decorator, "_REGISTERED_DESCRIPTORS", []) + monkeypatch.setattr(mgr_mod, "_manager", None) + if recovery_path == "periodic": + monkeypatch.setattr(mgr_mod, "_PERIODIC_RECOVERY_INTERVAL_SECONDS", 0.01) + eligible_ran = asyncio.Event() + excluded_calls = [] + + @task(name="protocol_disabled") + async def excluded(ctx: TaskContext[str]) -> str: + excluded_calls.append(ctx.input) + return "must not execute" + + @task(name="protocol_enabled") + async def eligible(ctx: TaskContext[str]) -> str: + eligible_ran.set() + return "recovered" + + manager = TaskManager(config=_config_stub(), provider=captured_local) + manager._disable_recovery_for(excluded.name) + started = False + try: + if recovery_path != "startup": + await manager.startup() + started = True + for primitive in (excluded, eligible): + info = await captured_local.create( + TaskCreateRequest( + id=primitive.name, + agent_name="test-agent", + session_id="test-session", + status="in_progress", + title=primitive.name, + payload={"input": "persisted", _SCHEMA_VERSION_KEY: _SCHEMA_VERSION}, + tags={"task_name": primitive.name}, + source=TaskManager._build_source(primitive.name), + lease_owner="test-agent|session:test-session", + lease_instance_id="interrupted-instance", + lease_duration_seconds=60, + ) + ) + info.lease.expires_at = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=10) + ).isoformat() + captured_local._delegate._write_task(info) + before = await captured_local.get(excluded.name) + captured_local.update_calls.clear() + if recovery_path == "startup": + await manager.startup() + started = True + elif recovery_path == "inline": + assert await manager.get_active_run(excluded.name) is None + assert await manager.get_active_run(eligible.name) is not None + await asyncio.wait_for(eligible_ran.wait(), timeout=5) + if recovery_path == "periodic": + + async def wait_for_another_scan(): + while len(captured_local.list_calls) < 3: + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_another_scan(), timeout=5) + after = await captured_local.get(excluded.name) + assert excluded_calls == [] + assert after.status == before.status == "in_progress" + assert after.etag == before.etag + assert after.lease.instance_id == before.lease.instance_id == "interrupted-instance" + assert after.lease.generation == before.lease.generation + assert after.payload == before.payload + assert not [call for call in captured_local.update_calls if call[0] == excluded.name] + finally: + if started: + await manager.shutdown() diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_lease_etag.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_lease_etag.py index d5b19ae89983..e1f3e713e7e9 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_lease_etag.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_recovery_lease_etag.py @@ -26,6 +26,7 @@ import pytest import azure.ai.agentserver.core.tasks._manager as mgr_mod +from azure.ai.agentserver.core.tasks import TaskContext from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider from azure.ai.agentserver.core.tasks._manager import TaskManager from azure.ai.agentserver.core.tasks._models import TaskCreateRequest, TaskPatchRequest @@ -44,6 +45,17 @@ def _config_stub(): )() +def _recovery_manager(provider: LocalFileTaskProvider) -> TaskManager: + manager = TaskManager(config=_config_stub(), provider=provider) + + async def reclaim_target(ctx: TaskContext[str]) -> str: + await ctx.shutdown.wait() + return ctx.input + + manager.register_resume_callback("reclaim_target", reclaim_target) + return manager + + async def _seed_stale_in_progress_task(provider: LocalFileTaskProvider, task_id: str = "t-recover") -> None: """Seed a framework-owned in_progress task whose lease is expired (simulating a crashed previous lifetime), so cold-start recovery @@ -79,7 +91,7 @@ async def test_recovery_reclaim_refreshes_tracked_etag(tmp_path: Path) -> None: pre = await provider.get(task_id) assert pre is not None - manager = TaskManager(config=_config_stub(), provider=provider) + manager = _recovery_manager(provider) mgr_mod._manager = manager await manager.startup() try: @@ -98,7 +110,7 @@ async def test_recovery_reclaim_refreshes_tracked_etag(tmp_path: Path) -> None: "renewal heartbeat would 412 and cancel recovery as 'lost ownership'" ) finally: - await manager.shutdown() + await manager.shutdown(wait_for_workers=True) mgr_mod._manager = None @@ -114,7 +126,7 @@ async def test_lease_renewal_after_recovery_does_not_412(tmp_path: Path) -> None task_id = "t-recover" await _seed_stale_in_progress_task(provider, task_id) - manager = TaskManager(config=_config_stub(), provider=provider) + manager = _recovery_manager(provider) mgr_mod._manager = manager await manager.startup() try: @@ -133,5 +145,5 @@ async def test_lease_renewal_after_recovery_does_not_412(tmp_path: Path) -> None assert renewed.lease.instance_id == manager._instance_id # noqa: SLF001 assert manager._get_tracked_etag(task_id) == renewed.etag # noqa: SLF001 finally: - await manager.shutdown() + await manager.shutdown(wait_for_workers=True) mgr_mod._manager = None diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_steering_attachment_queue.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_steering_attachment_queue.py index 2589372752d3..3d66fbd61994 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_steering_attachment_queue.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_steering_attachment_queue.py @@ -26,16 +26,19 @@ from azure.ai.agentserver.core.tasks import TaskContext, task, multi_turn_task from azure.ai.agentserver.core.tasks._attachments import ( + _FUNCTION_INPUT_KEY, + _INPUT_THRESHOLD_BYTES, _STEERING_INPUT_KEY_PREFIX, _STEERING_QUEUE_CAP, _STEERING_THRESHOLD_BYTES, _is_ref, + _read_input_value, _ref_key, ) from azure.ai.agentserver.core.tasks._exceptions import SteeringQueueFull from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider from azure.ai.agentserver.core.tasks._manager import TaskManager, set_task_manager -from azure.ai.agentserver.core.tasks._models import TaskPatchRequest +from azure.ai.agentserver.core.tasks._models import TaskInfo, TaskPatchRequest def _config_stub(session_id: str = "s018-steer-session"): @@ -156,6 +159,65 @@ async def runner(ctx: TaskContext[dict]) -> dict: # --------------------------------------------------------------------------- # +@pytest.mark.asyncio +@pytest.mark.parametrize("size", [0, _STEERING_THRESHOLD_BYTES + 100, _INPUT_THRESHOLD_BYTES + 100]) +async def test_drain_persists_current_input_before_handler( + manager_local: TaskManager, monkeypatch: pytest.MonkeyPatch, size: int +) -> None: + """The promoted turn is the persisted current input, in the same drain write.""" + first_started = asyncio.Event() + second_started = asyncio.Event() + finish = asyncio.Event() + snapshots: list[TaskInfo] = [] + drain_inputs: list[Any] = [] + task_id = "t-steer-current-input" + second_input = {"turn": "second", "value": "x" * size} + original_update = manager_local.provider.update + + async def capture_update(task_key: str, patch: TaskPatchRequest) -> TaskInfo: + if (patch.payload or {}).get("steering", {}).get("drain_in_progress"): + drain_inputs.append(_read_input_value((patch.payload or {}).get("input"), patch.attachments)) + return await original_update(task_key, patch) + + monkeypatch.setattr(manager_local.provider, "update", capture_update) + + @multi_turn_task(name="t-steer-current-input", steerable=True) + async def runner(ctx: TaskContext[dict[str, Any]]) -> None: + if ctx.input["turn"] == "first": + first_started.set() + await ctx.cancel.wait() + return + info = await manager_local.provider.get(task_id) + assert info is not None + snapshots.append(info) + second_started.set() + await finish.wait() + + try: + await runner.start(task_id=task_id, input={"turn": "first"}) + await asyncio.wait_for(first_started.wait(), timeout=3) + queued = await runner.start(task_id=task_id, input=second_input) + assert queued.is_queued + await asyncio.wait_for(second_started.wait(), timeout=3) + + assert drain_inputs == [second_input] + info = snapshots[0] + assert _read_input_value(info.payload["input"], info.attachments) == second_input + assert info.payload["steering"]["pending_inputs"] == [] + assert not any(key.startswith(_STEERING_INPUT_KEY_PREFIX) for key in info.attachments or {}) + if size > _INPUT_THRESHOLD_BYTES: + assert _is_ref(info.payload["input"]) + assert _ref_key(info.payload["input"]) == _FUNCTION_INPUT_KEY + assert info.attachments is not None + assert info.attachments[_FUNCTION_INPUT_KEY] == second_input + else: + assert info.payload["input"] == second_input + finally: + finish.set() + + await asyncio.wait_for(queued.result(), timeout=3) + + @pytest.mark.asyncio async def test_drain_does_not_renumber_existing_attachments(manager_local: TaskManager) -> None: """The user's concern: dequeue MUST NOT trigger re-upload / re-keying. diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_not_initialized.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_not_initialized.py index b9ba5d8a8b8a..830f08ed054d 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_not_initialized.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_not_initialized.py @@ -13,7 +13,7 @@ import pytest -from azure.ai.agentserver.core.tasks import TaskManagerNotInitialized +from azure.ai.agentserver.core.tasks import TaskContext, TaskManagerNotInitialized from azure.ai.agentserver.core.tasks._manager import ( TaskManager, get_task_manager, @@ -68,3 +68,134 @@ def test_get_task_manager_returns_installed_manager(tmp_path) -> None: ) set_task_manager(mgr) assert get_task_manager() is mgr + + +def test_scoped_manager_masks_other_host_and_receives_late_registration(tmp_path) -> None: + from azure.ai.agentserver.core.tasks import task + from azure.ai.agentserver.core.tasks._manager import _task_manager_scope + + first = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "first")) + second = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "second")) + set_task_manager(second) + with _task_manager_scope(lambda: first): + assert get_task_manager() is first + + @task(name="scoped_late_registration") + async def late(ctx: TaskContext[dict]) -> dict: + return ctx.input + + assert first._resume_callbacks[late.name] is late._fn + assert late.name not in second._resume_callbacks + with _task_manager_scope(lambda: None): + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + assert get_task_manager() is first + assert get_task_manager() is second + + +@pytest.mark.parametrize("explicit_registration", [False, True]) +@pytest.mark.asyncio +async def test_startup_preserves_explicit_callbacks_over_imported_descriptors( + tmp_path, monkeypatch: pytest.MonkeyPatch, explicit_registration: bool +) -> None: + from azure.ai.agentserver.core.tasks import _decorator, task + + monkeypatch.setattr(_decorator, "_REGISTERED_DESCRIPTORS", []) + + @task(name="startup_callback_owner") + async def owned(ctx: TaskContext[str]) -> str: + return "owned" + + @task(name="startup_callback_owner") + async def later_import(ctx: TaskContext[str]) -> str: + return "later import" + + manager = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "tasks")) + if explicit_registration: + manager.register_resume_callback(owned.name, owned._fn, owned._opts) + await manager.startup() + try: + expected = owned if explicit_registration else later_import + assert manager._resume_callbacks[expected.name] is expected._fn + assert manager._resume_opts[expected.name] is expected._opts + finally: + await manager.shutdown() + + +@pytest.mark.parametrize("explicit_registration", [False, True]) +@pytest.mark.asyncio +async def test_host_bound_callbacks_require_explicit_registration( + tmp_path, monkeypatch: pytest.MonkeyPatch, explicit_registration: bool +) -> None: + from azure.ai.agentserver.core.tasks import _decorator, task + from azure.ai.agentserver.core.tasks._models import TaskInfo + + monkeypatch.setattr(_decorator, "_REGISTERED_DESCRIPTORS", []) + monkeypatch.setattr(_decorator, "_HOST_BOUND_TASK_NAMES", set()) + with _decorator._host_bound_task_registration(): + + @task(name="host_bound") + async def owned(ctx: TaskContext[str]) -> str: + return ctx.input + + @task(name="ordinary_core") + async def ordinary(ctx: TaskContext[str]) -> str: + return ctx.input + + manager = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "tasks")) + if explicit_registration: + manager.register_resume_callback(owned.name, owned._fn, owned._opts) + await manager.startup() + try: + assert manager._resume_callbacks[ordinary.name] is ordinary._fn + info = TaskInfo( + id="persisted", agent_name="a", session_id="s", status="in_progress", source={"name": owned.name} + ) + assert manager._recovery_is_authorized(owned.name) is explicit_registration + assert (manager._find_resume_callback(info) is owned._fn) is explicit_registration + assert (owned.name in manager._resume_callbacks) is explicit_registration + finally: + await manager.shutdown() + + +def test_late_host_bound_registration_does_not_publish_into_live_manager( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + from azure.ai.agentserver.core.tasks import _decorator, task + from azure.ai.agentserver.core.tasks._models import TaskInfo + + monkeypatch.setattr(_decorator, "_HOST_BOUND_TASK_NAMES", set()) + manager = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "tasks")) + set_task_manager(manager) + with _decorator._host_bound_task_registration(): + + @task(name="late_host_bound") + async def owned(ctx: TaskContext[str]) -> str: + return ctx.input + + assert owned.name not in manager._resume_callbacks + assert not manager._recovery_is_authorized(owned.name) + info = TaskInfo(id="persisted", agent_name="a", session_id="s", status="in_progress", source={"name": owned.name}) + assert manager._find_resume_callback(info) is None + + +@pytest.mark.parametrize("source", [None, {}, {"name": "identity_target"}, {"name": "unknown"}, {"name": None}]) +@pytest.mark.parametrize("matching_title", [False, True]) +def test_explicit_identity_never_uses_legacy_fallback(tmp_path, source, matching_title: bool) -> None: + from azure.ai.agentserver.core.tasks._models import TaskInfo + + async def target(ctx: TaskContext[str]) -> str: + return ctx.input + + manager = TaskManager(config=_config_stub(), provider=LocalFileTaskProvider(tmp_path / "tasks")) + manager.register_resume_callback("identity_target", target) + info = TaskInfo( + id="persisted", + agent_name="a", + session_id="s", + status="in_progress", + title="identity_target old" if matching_title else "unrelated", + source=source, + ) + expected = target if source in (None, {}, {"name": "identity_target"}) else None + assert manager._find_resume_callback(info) is expected diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_optin.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_optin.py index 8d0150c78a57..f238321dd9aa 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_optin.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_task_manager_optin.py @@ -37,7 +37,7 @@ @pytest.fixture -def _clean_state(): +def _clean_state(monkeypatch: pytest.MonkeyPatch): """Snapshot/clear/restore the global registry, manager, and enable switch. ``_REGISTERED_DESCRIPTORS`` and the enable switch are process-global and @@ -46,6 +46,7 @@ def _clean_state(): """ saved_desc = list(_decorator_mod._REGISTERED_DESCRIPTORS) saved_enabled = resilient_tasks_enabled() + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "false") _decorator_mod._REGISTERED_DESCRIPTORS.clear() set_resilient_tasks_enabled(False) set_task_manager(None) @@ -67,11 +68,15 @@ def __init__(self, **kwargs) -> None: self.kwargs = kwargs self.startup_called = False self.shutdown_called = False + self.shutdown_signalled = False _FakeTaskManager.instances.append(self) async def startup(self) -> None: self.startup_called = True + def _signal_shutdown(self) -> None: + self.shutdown_signalled = True + async def shutdown(self) -> None: self.shutdown_called = True diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_us4_support.py b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_us4_support.py index d099ad246d60..41c32e99f993 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_us4_support.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/tasks/test_us4_support.py @@ -68,17 +68,22 @@ async def _teardown_manager(manager, mgr_mod): def test_drain_does_not_read_payload_input() -> None: """Source-level assertion that ``_try_drain_steering`` doesn't read payload['input']. - The drain primitive operates only on ``_steering`` sub-keys. Cleared input - on suspend (from T-082) does not break drain on subsequent resumes. + Drain reads from the steering queue, then writes the promoted current input. + Cleared input on suspend does not break subsequent steering turns. """ + import ast import inspect + import textwrap from azure.ai.agentserver.core.tasks._manager import TaskManager src = inspect.getsource(TaskManager._try_drain_steering) - # Drain reads `payload.get("steering", ...)` but never indexes - # `payload["input"]` or `payload.get("input", ...)`. - assert 'payload["input"]' not in src + tree = ast.parse(textwrap.dedent(src)) + assert not any( + isinstance(node.ctx, ast.Load) + for node in ast.walk(tree) + if isinstance(node, ast.Subscript) and ast.unparse(node) == "payload['input']" + ) assert 'payload.get("input"' not in src diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py index 1a8f05827a3c..c9df0474393c 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py @@ -39,6 +39,24 @@ def test_is_hosted_snapshotted_at_creation(self, monkeypatch: pytest.MonkeyPatch assert config.is_hosted is True +class TestAgentConfigSseKeepaliveInterval: + """Tests for SSE_KEEPALIVE_INTERVAL resolution.""" + + def test_defaults_to_fifteen_seconds(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SSE_KEEPALIVE_INTERVAL", raising=False) + + config = AgentConfig.from_env() + + assert config.sse_keepalive_interval == 15 + + def test_explicit_zero_disables_keepalive(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SSE_KEEPALIVE_INTERVAL", "0") + + config = AgentConfig.from_env() + + assert config.sse_keepalive_interval == 0 + + class TestAgentConfigAgentGuid: """Tests for the FOUNDRY_AGENT_ID (agent_guid) resolution.""" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py index 8a62deb21c56..5713fd2591f9 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_experimental.py @@ -5,15 +5,37 @@ import inspect import logging -from azure.ai.agentserver.core import experimental +from azure.ai.agentserver.core import ( + AgentSessionContext, + FoundryAgentRequestContext, + FoundryOriginalSubject, + experimental, +) from azure.ai.agentserver.core._experimental import ( DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, EXPERIMENTAL_CLASS_MESSAGE, - EXPERIMENTAL_LINK_MESSAGE, EXPERIMENTAL_METHOD_MESSAGE, _warning_cache, ) -from azure.ai.agentserver.core.tasks import multi_turn_task, resilient_tasks_enabled, task +from azure.ai.agentserver.core.tasks import ( + InputTooLarge, + LastInputIdPreconditionFailed, + MultiTurnTask, + RetryPolicy, + SteeringQueueFull, + Task, + TaskCancelled, + TaskConflictError, + TaskContext, + TaskDeferred, + TaskFailed, + TaskManagerNotInitialized, + TaskRun, + multi_turn_task, + resilient_tasks_enabled, + set_resilient_tasks_enabled, + task, +) @experimental @@ -69,9 +91,10 @@ def test_experimental_decorator_on_class(caplog) -> None: assert ExperimentalClass.__doc__.startswith(".. note::") assert EXPERIMENTAL_CLASS_MESSAGE in ExperimentalClass.__doc__ - assert EXPERIMENTAL_LINK_MESSAGE in ExperimentalClass.__doc__ assert len(caplog.records) == 1 assert EXPERIMENTAL_CLASS_MESSAGE in caplog.records[0].message + assert caplog.records[0].message.endswith("This is an experimental class, and may change at any time.") + assert "https://" not in ExperimentalClass.__doc__ def test_experimental_decorator_on_function(caplog) -> None: @@ -82,9 +105,10 @@ def test_experimental_decorator_on_function(caplog) -> None: assert experimental_function.__doc__.startswith(".. note::") assert EXPERIMENTAL_METHOD_MESSAGE in experimental_function.__doc__ - assert EXPERIMENTAL_LINK_MESSAGE in experimental_function.__doc__ assert len(caplog.records) == 1 assert EXPERIMENTAL_METHOD_MESSAGE in caplog.records[0].message + assert caplog.records[0].message.endswith("This is an experimental method, and may change at any time.") + assert "https://" not in experimental_function.__doc__ def test_experimental_decorator_on_async_function_preserves_coroutine_introspection(caplog) -> None: @@ -98,9 +122,10 @@ def test_experimental_decorator_on_async_function_preserves_coroutine_introspect assert experimental_async_function.__doc__.startswith(".. note::") assert EXPERIMENTAL_METHOD_MESSAGE in experimental_async_function.__doc__ - assert EXPERIMENTAL_LINK_MESSAGE in experimental_async_function.__doc__ assert len(caplog.records) == 1 assert EXPERIMENTAL_METHOD_MESSAGE in caplog.records[0].message + assert caplog.records[0].message.endswith("This is an experimental method, and may change at any time.") + assert "https://" not in experimental_async_function.__doc__ def test_experimental_decorator_no_duplicate_warnings(caplog) -> None: @@ -146,24 +171,86 @@ def test_experimental_decorator_env_var_suppresses_warning(monkeypatch, caplog) assert len(caplog.records) == 0 -def test_resilient_task_public_api_is_experimental(caplog) -> None: +def test_resilient_task_public_api_is_not_experimental(caplog) -> None: _warning_cache.clear() with caplog.at_level(logging.WARNING, logger=experimental.__module__): resilient_tasks_enabled() - assert resilient_tasks_enabled.__doc__.startswith(".. note::") - assert EXPERIMENTAL_METHOD_MESSAGE in resilient_tasks_enabled.__doc__ - assert len(caplog.records) == 1 + public_apis = ( + TaskContext, + TaskRun, + Task, + MultiTurnTask, + set_resilient_tasks_enabled, + resilient_tasks_enabled, + TaskFailed, + TaskCancelled, + TaskConflictError, + TaskManagerNotInitialized, + SteeringQueueFull, + LastInputIdPreconditionFailed, + InputTooLarge, + TaskDeferred, + RetryPolicy, + ) + for public_api in public_apis: + assert not (public_api.__doc__ or "").startswith(".. note::") + assert not hasattr(public_api, "_azure_agentserver_experimental_cache_key") + assert not hasattr(public_api, "_azure_agentserver_experimental_message") + + assert len(caplog.records) == 0 -def test_resilient_task_decorator_factories_are_experimental(caplog) -> None: +def test_resilient_task_decorator_factories_are_not_experimental(caplog) -> None: _warning_cache.clear() with caplog.at_level(logging.WARNING, logger=experimental.__module__): task(name="test-task") multi_turn_task(name="test-multi-turn-task") - assert task.__doc__.startswith(".. note::") - assert multi_turn_task.__doc__.startswith(".. note::") - assert len(caplog.records) == 2 + assert not (task.__doc__ or "").startswith(".. note::") + assert not (multi_turn_task.__doc__ or "").startswith(".. note::") + assert len(caplog.records) == 0 + + +def test_agent_session_context_is_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + AgentSessionContext("session", "restore") + + assert AgentSessionContext.__doc__.startswith(".. note::") + assert EXPERIMENTAL_CLASS_MESSAGE in AgentSessionContext.__doc__ + assert len(caplog.records) == 1 + + +def test_original_subject_is_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + subject = FoundryOriginalSubject(oid="subject", tid="tenant", idtype="user") + + assert (subject.oid, subject.tid, subject.idtype) == ("subject", "tenant", "user") + assert FoundryOriginalSubject.__doc__.startswith(".. note::") + assert EXPERIMENTAL_CLASS_MESSAGE in FoundryOriginalSubject.__doc__ + assert "https://" not in FoundryOriginalSubject.__doc__ + assert len(caplog.records) == 1 + assert "FoundryOriginalSubject" in caplog.records[0].message + assert EXPERIMENTAL_CLASS_MESSAGE in caplog.records[0].message + + +def test_model_attribution_headers_is_experimental(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + context = FoundryAgentRequestContext() + assert context.model_attribution_headers() == {} + + method = FoundryAgentRequestContext.model_attribution_headers + assert method.__doc__.startswith(".. note::") + assert EXPERIMENTAL_METHOD_MESSAGE in method.__doc__ + assert "https://" not in method.__doc__ + assert len(caplog.records) == 1 + assert "FoundryAgentRequestContext.model_attribution_headers" in caplog.records[0].message + assert EXPERIMENTAL_METHOD_MESSAGE in caplog.records[0].message diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_health.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_health.py index 0d8961b90fe5..b860f4972c08 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_health.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_health.py @@ -4,6 +4,8 @@ """Tests for the GET /readiness health-check endpoint.""" import pytest import httpx +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from azure.ai.agentserver.core import AgentServerHost @@ -32,6 +34,27 @@ async def test_readiness_content_type(client: httpx.AsyncClient) -> None: assert "application/json" in resp.headers["content-type"] +@pytest.mark.asyncio +async def test_readiness_handler_replaces_default_response() -> None: + agent = AgentServerHost() + + @agent.readiness_handler + async def readiness(request: Request) -> Response: + assert request.url.path == "/readiness" + return JSONResponse({"status": "not_ready"}, status_code=503) + + assert agent._readiness_fn is readiness # pylint: disable=protected-access + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as readiness_client: + resp = await readiness_client.get("/readiness") + + assert resp.status_code == 503 + assert resp.json() == {"status": "not_ready"} + + @pytest.mark.asyncio async def test_readiness_post_returns_405(client: httpx.AsyncClient) -> None: """POST /readiness is not allowed — only GET is registered.""" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_otlp_protocol.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_otlp_protocol.py index 0506b5e7a8d1..17748e9dc2b4 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_otlp_protocol.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_otlp_protocol.py @@ -10,6 +10,7 @@ import subprocess import sys from threading import Lock, Thread +from typing import Any import grpc import pytest @@ -35,8 +36,8 @@ configure_observability() -with trace.get_tracer("agentserver.otlp.test").start_as_current_span("otlp-test-span"): - pass +with trace.get_tracer("agentserver.otlp.test").start_as_current_span("otlp-test-span") as span: + span.set_attribute("microsoft.foundry", "framework-value") metrics.get_meter("agentserver.otlp.test").create_counter("otlp.test").add(1) @@ -47,6 +48,14 @@ body="otlp-test-log", ) ) +get_logger_provider().get_logger("agentserver.otlp.test").emit( + LogRecord( + severity_text="INFO", + severity_number=SeverityNumber.INFO, + body="otlp-conflicting-provider-log", + attributes={"microsoft.foundry": "other-value"}, + ) +) trace.get_tracer_provider().force_flush() metrics.get_meter_provider().force_flush() @@ -57,6 +66,7 @@ class _SignalReceiver: def __init__(self) -> None: self._signals: set[str] = set() + self.records: dict[str, list[Any]] = {"traces": [], "logs": []} self._lock = Lock() @property @@ -68,9 +78,23 @@ def signals(self) -> set[str]: def endpoint(self) -> str: raise NotImplementedError - def record(self, signal: str) -> None: + def record(self, signal: str, request: Any) -> None: with self._lock: self._signals.add(signal) + if signal == "traces": + self.records[signal].extend( + span + for resource in request.resource_spans + for scope in resource.scope_spans + for span in scope.spans + ) + elif signal == "logs": + self.records[signal].extend( + record + for resource in request.resource_logs + for scope in resource.scope_logs + for record in scope.log_records + ) def __enter__(self) -> "_SignalReceiver": raise NotImplementedError @@ -112,8 +136,8 @@ def do_POST(self) -> None: # pylint: disable=invalid-name signal, request_type, response_type = service body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) - request_type.FromString(body) - receiver.record(signal) + request = request_type.FromString(body) + receiver.record(signal, request) response = response_type().SerializeToString() self.send_response(200) @@ -153,21 +177,21 @@ class TraceService(trace_service_pb2_grpc.TraceServiceServicer): def Export( self, request, context ): # pylint: disable=invalid-name,unused-argument - receiver.record("traces") + receiver.record("traces", request) return trace_service_pb2.ExportTraceServiceResponse() class MetricsService(metrics_service_pb2_grpc.MetricsServiceServicer): def Export( self, request, context ): # pylint: disable=invalid-name,unused-argument - receiver.record("metrics") + receiver.record("metrics", request) return metrics_service_pb2.ExportMetricsServiceResponse() class LogsService(logs_service_pb2_grpc.LogsServiceServicer): def Export( self, request, context ): # pylint: disable=invalid-name,unused-argument - receiver.record("logs") + receiver.record("logs", request) return logs_service_pb2.ExportLogsServiceResponse() self._server = grpc.server(ThreadPoolExecutor(max_workers=3)) @@ -236,6 +260,13 @@ def test_otlp_protocol_exports_all_signals( f"{protocol} receiver got {sorted(receiver.signals)} instead of " f"{sorted(_SIGNALS)}.\nsubprocess stderr:\n{result.stderr}" ) + assert any(span.name == "otlp-test-span" for span in receiver.records["traces"]) + log_bodies = {record.body.string_value for record in receiver.records["logs"]} + assert {"otlp-test-log", "otlp-conflicting-provider-log"} <= log_bodies + for records in receiver.records.values(): + for record in records: + attributes = {attr.key: attr.value.string_value for attr in record.attributes} + assert attributes.get("microsoft.foundry") == "True" if protocol == "grpc": assert "otlp-test-span" not in result.stdout assert "otlp-test-log" not in result.stdout diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py index 9c59cb2af7ac..6e150d1f58d2 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py @@ -5,15 +5,388 @@ from __future__ import annotations import asyncio +from dataclasses import FrozenInstanceError +from urllib.parse import quote, unquote + +import httpx +import pytest +from starlette.datastructures import Headers from azure.ai.agentserver.core import ( FoundryAgentRequestContext, + FoundryOriginalSubject, get_request_context, reset_request_context, set_request_context, ) from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID, USER_ID +SUBJECT_HEADER = "x-ms-original-subject" +SUBJECT_VALUE = 'oid="original-oid", tid="original-tid", idtype="user"' +SUBJECT_HEADERS = {SUBJECT_HEADER: SUBJECT_VALUE} +BAGGAGE_HEADER = "x-ms-foundry-baggage" + + +def model_headers(value: str) -> dict[str, str]: + return {BAGGAGE_HEADER: f"foundry.original_subject={quote(value, safe='')}"} + + +class TestOriginalSubject: + def test_absent_subject_does_not_infer_from_state_or_call_id(self) -> None: + context = FoundryAgentRequestContext(user_id="raw-state-user", call_id="opaque-call") + assert context.original_subject is None + assert context.model_attribution_headers() == {} + assert FoundryOriginalSubject.from_headers(Headers({USER_ID: "raw-state-user"})) is None + + @pytest.mark.parametrize("idtype", ["user", "app", "unknown"]) + def test_model_headers_preserve_tuple_and_platform_headers_are_unchanged(self, idtype: str) -> None: + expected = {SUBJECT_HEADER: f'oid="original-oid", tid="original-tid", idtype="{idtype}"'} + context = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject.from_headers(Headers(expected)), + call_id="opaque-call", + user_id="different-state-key", + ) + assert context.model_attribution_headers() == { + **model_headers(expected[SUBJECT_HEADER]), + FOUNDRY_CALL_ID: "opaque-call", + } + assert context.platform_headers() == {FOUNDRY_CALL_ID: "opaque-call"} + copied = context.model_attribution_headers() + assert not any(name.lower().startswith(SUBJECT_HEADER) for name in copied) + assert unquote(copied[BAGGAGE_HEADER].split("=", 1)[1]) == expected[SUBJECT_HEADER] + copied[BAGGAGE_HEADER] = "changed" + assert ( + context.model_attribution_headers()[BAGGAGE_HEADER] + == model_headers(expected[SUBJECT_HEADER])[BAGGAGE_HEADER] + ) + + @pytest.mark.parametrize( + "value,oid,tid,idtype", + [ + ('tid="original-tid", idtype="user"', None, "original-tid", "user"), + ('oid="original-oid", idtype="user"', "original-oid", None, "user"), + ('oid="original-oid", tid="original-tid"', "original-oid", "original-tid", None), + ('oid="", tid="original-tid", idtype="user"', "", "original-tid", "user"), + ('oid="original-oid", tid=" ", idtype="user"', "original-oid", " ", "user"), + ], + ) + def test_partial_or_empty_subject_is_preserved_but_cannot_be_forwarded( + self, value: str, oid: str | None, tid: str | None, idtype: str | None + ) -> None: + subject = FoundryOriginalSubject.from_headers({SUBJECT_HEADER: value}) + assert subject is not None + assert (subject.oid, subject.tid, subject.idtype) == (oid, tid, idtype) + with pytest.raises(ValueError, match="complete, HTTP-safe tuple"): + FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() + + @pytest.mark.parametrize("invalid", [None, "", " ", "oid\r\ninjected", "\t", "\x00", "\x7f", "\N{SNOWMAN}"]) + def test_constructed_unsafe_subject_cannot_be_forwarded(self, invalid: str | None) -> None: + subject = FoundryOriginalSubject(oid=invalid, tid="tenant", idtype="user") + with pytest.raises(ValueError, match="complete, HTTP-safe tuple"): + FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "original-oid,original-tid,user", + 'oid="one", oid="two"', + 'oid="one", oid="one"', + 'oid="one", extra="value"', + 'oid="one";extra="value"', + "oid=null", + "oid=user", + "oid=123", + "oid=?1", + 'OID="one"', + 'oid = "one"', + 'oid="unterminated', + 'oid="one",', + ',oid="one"', + 'oid="one",,tid="two"', + 'oid="one"tid="two"', + r'oid="bad\escape"', + 'oid="bad\r\ninjected"', + 'oid="bad\tinjected"', + 'oid="bad\x00"', + 'oid="bad\x7f"', + 'oid="\N{SNOWMAN}"', + ], + ) + def test_malformed_or_ambiguous_dictionary_is_rejected(self, value: str) -> None: + with pytest.raises(ValueError, match="Original-subject attribution"): + FoundryOriginalSubject.from_headers({SUBJECT_HEADER: value}) + + def test_duplicate_header_fields_are_rejected(self) -> None: + headers = Headers( + raw=[ + (SUBJECT_HEADER.encode(), b'oid="one"'), + (SUBJECT_HEADER.encode(), b'tid="two", idtype="user"'), + ] + ) + with pytest.raises(ValueError, match="single header field"): + FoundryOriginalSubject.from_headers(headers) + + def test_order_and_optional_whitespace_do_not_change_the_snapshot(self) -> None: + subject = FoundryOriginalSubject.from_headers( + { + "X-MS-Original-Subject": (" \t" 'idtype="user" \t,tid="original-tid",\t oid="original-oid"\t '), + } + ) + assert subject == FoundryOriginalSubject(oid="original-oid", tid="original-tid", idtype="user") + assert FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() == model_headers( + SUBJECT_VALUE + ) + + def test_structured_fields_escaping_round_trips_without_splitting_quoted_commas(self) -> None: + value = r'oid="original\\oid\",branch", tid="original-tid", idtype="user"' + subject = FoundryOriginalSubject.from_headers({SUBJECT_HEADER: value}) + assert subject == FoundryOriginalSubject(oid='original\\oid",branch', tid="original-tid", idtype="user") + assert FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() == model_headers(value) + + @pytest.mark.parametrize("present", [False, True]) + def test_merge_replaces_reserved_subjects_without_mutating_headers(self, present: bool) -> None: + existing = { + "Authorization": "test-key", + "X-MS-Original-Subject": "stale", + "x-ms-original-subject": "also-stale", + "X-MS-Original-Subject-Oid": "stale", + "X-MS-Original-Subject-Tid": "stale", + "X-MS-Original-Subject-Idtype": "stale", + "X-AGENT-FOUNDRY-CALL-ID": "stale-call", + "X-MS-Foundry-Baggage": ( + "app=example,Foundry.Original_Subject=stale," + "foundry.original_subject.oid=stale,FOUNDry.original_subject.tid=stale" + ), + "x-ms-foundry-baggage": ( + "trace=opaque%252Cvalue;flag,app=example," + "FOUNDRY.ORIGINAL_SUBJECT=other,foundry.original_subject.idtype=app" + ), + } + snapshot = existing.copy() + context = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid="current", tid="tenant", idtype="app") if present else None, + call_id="current-call", + ) + result = context.model_attribution_headers(existing) + assert existing == snapshot + assert result["Authorization"] == "test-key" + members = result[BAGGAGE_HEADER].split(",") + unrelated = members[1:] if present else members + assert unrelated == ["app=example", "trace=opaque%252Cvalue;flag", "app=example"] + keys = [member.split("=", 1)[0].lower() for member in members] + assert keys.count("foundry.original_subject") == int(present) + assert not any(key.startswith("foundry.original_subject.") for key in keys) + assert all(name in {"Authorization", BAGGAGE_HEADER, FOUNDRY_CALL_ID} for name in result) + assert not any(name.lower().startswith(SUBJECT_HEADER) for name in result) + if present: + assert unquote(members[0].split("=", 1)[1]) == 'oid="current", tid="tenant", idtype="app"' + assert result[FOUNDRY_CALL_ID] == "current-call" + else: + assert result == {"Authorization": "test-key", BAGGAGE_HEADER: ",".join(members[:3])} + result[BAGGAGE_HEADER] = "changed" + assert context.model_attribution_headers(existing)[BAGGAGE_HEADER] != "changed" + + def test_reserved_only_headers_disappear_without_a_subject(self) -> None: + assert ( + FoundryAgentRequestContext().model_attribution_headers( + { + "X-MS-Original-Subject": SUBJECT_VALUE, + BAGGAGE_HEADER: "foundry.original_subject=stale,FOUNDry.original_subject.oid=stale", + } + ) + == {} + ) + + @pytest.mark.parametrize("present", [False, True]) + @pytest.mark.parametrize( + "stale", + [ + "Foundry.Original_Subject;stale=1", + "foundry.original_subject;stale", + "FOUNDry.original_subject.oid;stale=1", + "foundry%2Eoriginal_subject;stale=1", # cspell:disable-line + ], + ) + def test_reserved_members_with_properties_are_removed(self, present: bool, stale: str) -> None: + subject = FoundryOriginalSubject(oid="current", tid="tenant", idtype="user") if present else None + existing = {BAGGAGE_HEADER: f"foundry.project.id=project,{stale}"} + result = FoundryAgentRequestContext(original_subject=subject).model_attribution_headers(existing) + expected = "foundry.project.id=project" + if subject is not None: + expected = model_headers('oid="current", tid="tenant", idtype="user"')[BAGGAGE_HEADER] + "," + expected + assert result[BAGGAGE_HEADER] == expected + assert existing[BAGGAGE_HEADER] == f"foundry.project.id=project,{stale}" + + @pytest.mark.parametrize("present", [False, True]) + def test_unrelated_duplicate_baggage_is_preserved_without_reinterpreting_caller(self, present: bool) -> None: + baggage = ( + "Foundry.Caller.Identity.Oid=caller-a,foundry.caller.identity.oid=caller-b," + "foundry.project.id=project-a,foundry.project.id=project-b," + "foundry.agent.name=opaque%252Cvalue;flag=true" + ) + existing = {"Authorization": "test-key", BAGGAGE_HEADER: baggage} + subject = FoundryOriginalSubject(oid="current", tid="tenant", idtype="user") if present else None + result = FoundryAgentRequestContext(original_subject=subject).model_attribution_headers(existing) + expected = baggage + if subject is not None: + expected = model_headers('oid="current", tid="tenant", idtype="user"')[BAGGAGE_HEADER] + "," + expected + assert result[BAGGAGE_HEADER] == expected + assert result["Authorization"] == existing["Authorization"] + assert existing[BAGGAGE_HEADER] == baggage + + @pytest.mark.parametrize("length", [256, 257]) + @pytest.mark.parametrize("character", ["x", '"', "\\"]) + def test_serialized_subject_transport_bound(self, length: int, character: str) -> None: + fixed_length = len('oid="", tid="tenant", idtype="user"') + available = length - fixed_length + escaped_length = 1 if character == "x" else 2 + oid = character * (available // escaped_length) + "x" * (available % escaped_length) + context = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid=oid, tid="tenant", idtype="user") + ) + if length > 256: + with pytest.raises(ValueError, match="256"): + context.model_attribution_headers() + else: + result = context.model_attribution_headers() + value = unquote(result[BAGGAGE_HEADER].split("=", 1)[1]) + escaped_oid = oid.replace("\\", "\\\\").replace('"', '\\"') + assert len(value) == length + assert value == f'oid="{escaped_oid}", tid="tenant", idtype="user"' + assert not any(name.lower().startswith(SUBJECT_HEADER) for name in result) + + @pytest.mark.parametrize("length", [1024, 1025]) + def test_merged_baggage_transport_bound_without_mutating_headers(self, length: int) -> None: + context = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid="current", tid="tenant", idtype="user") + ) + encoded = context.model_attribution_headers()[BAGGAGE_HEADER] + prefix = ",".join(f"foundry.keep{index}=" + "x" * 200 for index in range(4)) + ",foundry.tail=" + baggage = prefix + "x" * (length - len(prefix) - 1 - len(encoded)) + existing_baggage = f"{baggage},foundry.original_subject=" + "x" * 2048 + existing = {BAGGAGE_HEADER: existing_baggage, "Authorization": "test-key"} + if length > 1024: + with pytest.raises(ValueError, match="1024"): + context.model_attribution_headers(existing) + else: + result = context.model_attribution_headers(existing) + assert len(result[BAGGAGE_HEADER]) == length + assert result[BAGGAGE_HEADER] == f"{encoded},{baggage}" + assert existing == {BAGGAGE_HEADER: existing_baggage, "Authorization": "test-key"} + + def test_subject_free_call_preserves_unrelated_baggage_outside_attribution_limits(self) -> None: + baggage = "foundry.example=" + "x" * 2048 + existing = {BAGGAGE_HEADER: baggage + ",Foundry.Original_Subject;stale=1"} + assert FoundryAgentRequestContext().model_attribution_headers(existing) == {BAGGAGE_HEADER: baggage} + + def test_subject_precedes_baggage_that_can_expand_during_transport_normalization(self) -> None: + context = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid="current", tid="tenant", idtype="user") + ) + baggage = "foundry.keep0=" + "!" * 200 + ",foundry.keep1=" + "!" * 120 + existing = {BAGGAGE_HEADER: baggage} + encoded = context.model_attribution_headers()[BAGGAGE_HEADER] + result = context.model_attribution_headers(existing) + assert result[BAGGAGE_HEADER] == f"{encoded},{baggage}" + assert len(result[BAGGAGE_HEADER]) < 1024 + assert existing == {BAGGAGE_HEADER: baggage} + + def test_baggage_encodes_full_quoted_tuple_exactly_once(self) -> None: + subject = FoundryOriginalSubject(oid='a%2C\\",=b', tid="tenant", idtype="user") + result = FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() + encoded = result[BAGGAGE_HEADER].split("=", 1)[1] + assert "%252C" in encoded + assert unquote(encoded) == r'oid="a%2C\\\",=b", tid="tenant", idtype="user"' + assert not any(name.lower().startswith(SUBJECT_HEADER) for name in result) + assert len(result[BAGGAGE_HEADER].split(",")) == 1 + + def test_typical_complete_tuple_fits_existing_carrier_limits(self) -> None: + subject = FoundryOriginalSubject( + oid="11111111-2222-3333-4444-555555555555", + tid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + idtype="user", + ) + result = FoundryAgentRequestContext(original_subject=subject).model_attribution_headers() + assert len(result[BAGGAGE_HEADER]) <= 1024 + assert len(unquote(result[BAGGAGE_HEADER].split("=", 1)[1])) <= 256 + + def test_retired_headers_are_not_used_as_fallback_or_mixed_into_the_snapshot(self) -> None: + retired = { + "x-ms-original-subject-oid": "retired-oid", + "x-ms-original-subject-tid": "retired-tid", + "x-ms-original-subject-idtype": "app", + } + assert FoundryOriginalSubject.from_headers(retired) is None + subject = FoundryOriginalSubject.from_headers({**retired, **SUBJECT_HEADERS}) + assert subject == FoundryOriginalSubject(oid="original-oid", tid="original-tid", idtype="user") + + def test_snapshot_is_immutable_and_does_not_print_identifiers(self) -> None: + subject = FoundryOriginalSubject(oid="private-original-oid", tid="tenant", idtype="user") + with pytest.raises(FrozenInstanceError): + setattr(subject, "oid", "different") + assert "private-original-oid" not in repr(subject) + + @pytest.mark.asyncio + async def test_shared_client_parallel_users_and_retries_keep_per_call_headers(self) -> None: + both_bound = asyncio.Event() + bound = 0 + attempts: dict[str, int] = {} + observed: list[httpx.Headers] = [] + + def receive(request: httpx.Request) -> httpx.Response: + observed.append(request.headers.copy()) + oid = request.headers[FOUNDRY_CALL_ID].removeprefix("call-") + attempts[oid] = attempts.get(oid, 0) + 1 + return httpx.Response(503 if attempts[oid] == 1 else 200) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(receive), + headers={"Authorization": "Bearer model-agent-token"}, + ) as client: + client.headers[BAGGAGE_HEADER] = "application=shared" + + async def invoke(user: str) -> None: + nonlocal bound + token = set_request_context( + FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid=user, tid=f"tenant-{user}", idtype="user"), + call_id=f"call-{user}", + ) + ) + try: + bound += 1 + if bound == 2: + both_bound.set() + await both_bound.wait() + for _ in range(2): + response = await client.post( + "https://model.test/responses", + headers=get_request_context().model_attribution_headers(client.headers), + json={"input": "test"}, + ) + assert response.status_code == 200 + finally: + reset_request_context(token) + assert get_request_context().original_subject is None + + await asyncio.gather(invoke("alice"), invoke("bob")) + assert SUBJECT_HEADER not in client.headers + assert client.headers[BAGGAGE_HEADER] == "application=shared" + + assert attempts == {"alice": 2, "bob": 2} + for headers in observed: + user = headers[FOUNDRY_CALL_ID].removeprefix("call-") + assert headers[FOUNDRY_CALL_ID] == f"call-{user}" + baggage = headers[BAGGAGE_HEADER].split(",") + assert baggage[1] == "application=shared" + assert unquote(baggage[0].split("=", 1)[1]) == f'oid="{user}", tid="tenant-{user}", idtype="user"' + assert not any(name.lower().startswith(SUBJECT_HEADER) for name in headers) + assert headers["authorization"] == "Bearer model-agent-token" + assert USER_ID not in headers + assert get_request_context().original_subject is None + class TestRequestContext: def test_default_is_empty(self) -> None: @@ -23,9 +396,7 @@ def test_default_is_empty(self) -> None: assert ctx.session_id is None def test_set_and_reset(self) -> None: - token = set_request_context( - FoundryAgentRequestContext(call_id="c1", user_id="u1", session_id="s1") - ) + token = set_request_context(FoundryAgentRequestContext(call_id="c1", user_id="u1", session_id="s1")) try: ctx = get_request_context() assert ctx.call_id == "c1" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_lifecycle.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_lifecycle.py new file mode 100644 index 000000000000..12822e149344 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_lifecycle.py @@ -0,0 +1,1158 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for the private memory snapshot lifecycle endpoints.""" + +import asyncio +import os +from types import MappingProxyType +from unittest import mock + +import httpx +import pytest +from starlette.responses import JSONResponse +from starlette.routing import Route + +from azure.ai.agentserver.core import ( + AgentServerHost, + AgentSessionContext, + FoundryAgentRequestContext, + get_request_context, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.core.tasks._lease import derive_lease_owner +from azure.ai.agentserver.core.tasks import resilient_tasks_enabled, set_resilient_tasks_enabled +from azure.ai.agentserver.core.tasks._manager import TaskManager, get_task_manager, set_task_manager + + +@pytest.fixture(autouse=True) +def restore_process_environment(): + captured_environment = dict(os.environ) + yield + os.environ.clear() + os.environ.update(captured_environment) + + +def _after_restore_payload( + *, + session_id: str = "session-1", + restore_id: str = "restore-1", + overrides: dict[str, str] | None = None, +) -> dict[str, object]: + return { + "session_context": { + "session_id": session_id, + "restore_id": restore_id, + "session_env_overrides": overrides or {}, + } + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("before_path", "after_path"), + [ + ("/_agent/before-snapshot", "/_agent/after-restore"), + ("/_before_snapshot", "/_after_restore"), + ], +) +async def test_default_lifecycle_handlers_are_successful_noops( + client: httpx.AsyncClient, + before_path: str, + after_path: str, +) -> None: + before_response = await client.post(before_path, json={}) + after_response = await client.post( + after_path, + json=_after_restore_payload(), + ) + + assert before_response.status_code == 200 + assert before_response.json() == {"status": "ok"} + assert after_response.status_code == 200 + assert after_response.json() == {"status": "ok"} + assert "application/json" in before_response.headers["content-type"] + assert "application/json" in after_response.headers["content-type"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path", + [ + "/_agent/before-snapshot", + "/_agent/after-restore", + "/_before_snapshot", + "/_after_restore", + ], +) +async def test_lifecycle_routes_only_accept_post( + client: httpx.AsyncClient, + path: str, +) -> None: + assert (await client.get(path)).status_code == 405 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "content", "headers"), + [ + ("/_agent/before-snapshot", b"", {"content-type": "application/json"}), + ("/_agent/before-snapshot", b"[]", {"content-type": "application/json"}), + ("/_agent/before-snapshot", b"{", {"content-type": "application/json"}), + ("/_agent/before-snapshot", b"{}", {"content-type": "text/plain"}), + ("/_agent/after-restore", b"", {"content-type": "application/json"}), + ("/_agent/after-restore", b"null", {"content-type": "application/json"}), + ("/_agent/after-restore", b"{", {"content-type": "application/json"}), + ("/_agent/after-restore", b"{}", {"content-type": "text/plain"}), + ], +) +async def test_lifecycle_routes_reject_invalid_json_requests( + client: httpx.AsyncClient, + path: str, + content: bytes, + headers: dict[str, str], +) -> None: + response = await client.post(path, content=content, headers=headers) + + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"].startswith("invalid_") + assert set(error) == {"code", "message"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {}, + {"session_context": None}, + {"session_context": {}}, + {"session_context": {"session_id": "", "restore_id": "restore-1"}}, + _after_restore_payload(session_id="invalid\0session"), + {"session_context": {"session_id": "session-1", "restore_id": ""}}, + { + "session_context": { + "session_id": "session-1", + "restore_id": "restore-1", + "session_env_overrides": [], + } + }, + { + "session_context": { + "session_id": "session-1", + "restore_id": "restore-1", + "session_env_overrides": {"COUNT": 1}, + } + }, + _after_restore_payload(overrides={"": "value"}), + _after_restore_payload(overrides={"INVALID=NAME": "value"}), + _after_restore_payload(overrides={"VALUE": "invalid\0value"}), + _after_restore_payload( + session_id="session-1", + overrides={"FOUNDRY_AGENT_SESSION_ID": "session-2"}, + ), + ], +) +async def test_after_restore_rejects_invalid_session_context( + client: httpx.AsyncClient, + payload: dict[str, object], +) -> None: + response = await client.post("/_agent/after-restore", json=payload) + + assert response.status_code == 400 + assert response.json() == { + "error": { + "code": "invalid_after_restore_request", + "message": "The after-restore request is invalid.", + } + } + + +@pytest.mark.asyncio +async def test_after_restore_validates_explicit_session_guid_in_hosted_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_GUID", raising=False) + agent = AgentServerHost() + callback_count = 0 + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + nonlocal callback_count + callback_count += 1 + assert context.session_env_overrides["FOUNDRY_AGENT_SESSION_GUID"] == "a" * 32 + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + invalid = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + overrides={"FOUNDRY_AGENT_SESSION_GUID": "invalid-guid"}, + ), + ) + + assert invalid.status_code == 400 + assert invalid.json()["error"]["code"] == "invalid_after_restore_request" + assert callback_count == 0 + assert "FOUNDRY_AGENT_SESSION_GUID" not in os.environ + assert agent.config.session_guid == "" + + valid = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + overrides={"FOUNDRY_AGENT_SESSION_GUID": "a" * 32}, + ), + ) + + assert valid.status_code == 200 + assert callback_count == 1 + assert agent.config.session_guid == "a" * 32 + + +@pytest.mark.asyncio +async def test_after_restore_allows_non_guid_session_value_outside_hosted_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + agent = AgentServerHost() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + overrides={"FOUNDRY_AGENT_SESSION_GUID": "legacy-session-value"}, + ), + ) + + assert response.status_code == 200 + assert agent.config.session_guid == "legacy-session-value" + + +@pytest.mark.asyncio +async def test_unknown_request_fields_are_ignored(client: httpx.AsyncClient) -> None: + before_response = await client.post( + "/_agent/before-snapshot", + json={"future_context": {"enabled": True}}, + ) + payload = _after_restore_payload() + payload["future_top_level"] = True + session_context = payload["session_context"] + assert isinstance(session_context, dict) + session_context["future_nested"] = {"value": 1} + + after_response = await client.post("/_agent/after-restore", json=payload) + + assert before_response.status_code == 200 + assert after_response.status_code == 200 + + +@pytest.mark.asyncio +async def test_after_restore_applies_environment_before_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "captured-session") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "captured-guid") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example") + monkeypatch.delenv("RESTORED_VALUE", raising=False) + agent = AgentServerHost() + config = agent.config + observed: dict[str, object] = {} + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + observed["session_id"] = os.environ["FOUNDRY_AGENT_SESSION_ID"] + observed["session_guid"] = os.environ["FOUNDRY_AGENT_SESSION_GUID"] + observed["restored_value"] = os.environ["RESTORED_VALUE"] + observed["config_session_id"] = agent.config.session_id + observed["config_session_guid"] = agent.config.session_guid + observed["config_project_endpoint"] = agent.config.project_endpoint + observed["config_identity_preserved"] = agent.config is config + observed["context"] = context + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + session_id="restored-session", + overrides={ + "FOUNDRY_AGENT_SESSION_GUID": "restored-guid", + "FOUNDRY_PROJECT_ENDPOINT": "https://restored.example", + "RESTORED_VALUE": "available", + }, + ), + ) + + assert response.status_code == 200 + assert observed["session_id"] == "restored-session" + assert observed["session_guid"] == "restored-guid" + assert observed["restored_value"] == "available" + assert observed["config_session_id"] == "restored-session" + assert observed["config_session_guid"] == "restored-guid" + assert observed["config_project_endpoint"] == "https://restored.example" + assert observed["config_identity_preserved"] is True + context = observed["context"] + assert isinstance(context, AgentSessionContext) + assert isinstance(context.session_env_overrides, MappingProxyType) + assert context.session_env_overrides == { + "FOUNDRY_AGENT_SESSION_GUID": "restored-guid", + "FOUNDRY_PROJECT_ENDPOINT": "https://restored.example", + "RESTORED_VALUE": "available", + } + + +@pytest.mark.asyncio +async def test_subclass_hooks_run_before_application_callbacks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + events: list[str] = [] + + class ExtendedAgentServerHost(AgentServerHost): + async def _before_snapshot(self) -> None: + events.append("subclass-before-snapshot") + + async def _after_restore(self, context: AgentSessionContext) -> None: + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == context.session_id + assert self.config.session_id == context.session_id + events.append("subclass-after-restore") + + agent = ExtendedAgentServerHost() + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + events.append("application-before-snapshot") + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == context.session_id + assert agent.config.session_id == context.session_id + events.append("application-after-restore") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + before_response = await lifecycle_client.post( + "/_agent/before-snapshot", + json={}, + ) + after_response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(), + ) + + assert before_response.status_code == 200 + assert after_response.status_code == 200 + assert events == [ + "subclass-before-snapshot", + "application-before-snapshot", + "subclass-after-restore", + "application-after-restore", + ] + + +@pytest.mark.asyncio +async def test_subclass_before_snapshot_failure_skips_application_callback_and_retries() -> None: + events: list[str] = [] + + class ExtendedAgentServerHost(AgentServerHost): + async def _before_snapshot(self) -> None: + events.append("subclass") + if events.count("subclass") == 1: + raise RuntimeError("subclass failure") + + agent = ExtendedAgentServerHost() + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + events.append("application") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + failed = await lifecycle_client.post("/_agent/before-snapshot", json={}) + retried = await lifecycle_client.post("/_agent/before-snapshot", json={}) + + assert failed.status_code == 500 + assert retried.status_code == 200 + assert events == ["subclass", "subclass", "application"] + + +@pytest.mark.asyncio +async def test_subclass_after_restore_failure_rolls_back_and_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "captured-session") + events: list[str] = [] + + class ExtendedAgentServerHost(AgentServerHost): + async def _after_restore(self, context: AgentSessionContext) -> None: + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == context.session_id + assert self.config.session_id == context.session_id + events.append("subclass") + if events.count("subclass") == 1: + raise RuntimeError("subclass failure") + + def _restore_lifecycle_environment(self, *_args, **_kwargs) -> None: + raise AssertionError("subclasses must not replace framework rollback") + + agent = ExtendedAgentServerHost() + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == context.session_id + events.append("application") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + failed = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="restored-session"), + ) + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == "captured-session" + assert agent.config.session_id == "captured-session" + + different_session = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + session_id="different-session", + restore_id="restore-2", + ), + ) + retried = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="restored-session"), + ) + + assert failed.status_code == 500 + assert different_session.status_code == 409 + assert different_session.json()["error"]["code"] == "session_mismatch" + assert retried.status_code == 200 + assert events == ["subclass", "subclass", "application"] + + +@pytest.mark.asyncio +async def test_after_restore_rehydrates_task_manager_session_state_and_rolls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "test-agent") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "captured-session") + previous_enabled = resilient_tasks_enabled() + set_resilient_tasks_enabled(True) + provider = mock.Mock() + provider.list = mock.AsyncMock(return_value=[]) + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(lambda config: provider)) + agent = AgentServerHost() + task_manager = TaskManager(config=agent.config, provider=provider) + agent._task_manager = task_manager # pylint: disable=protected-access + set_task_manager(task_manager) + captured_instance_id = task_manager._instance_id # pylint: disable=protected-access + restored_instance_ids: list[str] = [] + restored_managers: list[TaskManager] = [] + callback_count = 0 + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + nonlocal callback_count + callback_count += 1 + current_manager = get_task_manager() + restored_managers.append(current_manager) + assert current_manager._lease_owner == derive_lease_owner( # pylint: disable=protected-access + "test-agent", + context.session_id, + ) + restored_instance_ids.append(current_manager._instance_id) # pylint: disable=protected-access + if callback_count == 1: + raise RuntimeError("restore failed") + + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + failed = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="restored-session"), + ) + + assert failed.status_code == 500 + assert task_manager._lease_owner == derive_lease_owner( # pylint: disable=protected-access + "test-agent", + "captured-session", + ) + assert agent._task_manager is None + assert restored_managers[0]._shutdown_event.is_set() + + retried = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="restored-session"), + ) + finally: + await agent._shutdown_task_manager() + set_task_manager(None) + set_resilient_tasks_enabled(previous_enabled) + + assert retried.status_code == 200 + assert restored_managers[1]._lease_owner == derive_lease_owner( # pylint: disable=protected-access + "test-agent", + "restored-session", + ) + assert restored_instance_ids[0] != captured_instance_id + assert restored_managers[0] is not restored_managers[1] + assert restored_instance_ids[0] != restored_instance_ids[1] + + +@pytest.mark.asyncio +async def test_protocol_subclass_hooks_compose_through_super() -> None: + events: list[str] = [] + + class BaseProtocolHost(AgentServerHost): + async def _before_snapshot(self) -> None: + await super()._before_snapshot() + events.append("base-before") + + async def _after_restore(self, context: AgentSessionContext) -> None: + await super()._after_restore(context) + events.append("base-after") + + class DerivedProtocolHost(BaseProtocolHost): + async def _before_snapshot(self) -> None: + await super()._before_snapshot() + events.append("derived-before") + + async def _after_restore(self, context: AgentSessionContext) -> None: + await super()._after_restore(context) + events.append("derived-after") + + agent = DerivedProtocolHost() + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + events.append("application-before") + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + del context + events.append("application-after") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + assert (await lifecycle_client.post("/_agent/before-snapshot", json={})).status_code == 200 + assert ( + await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(), + ) + ).status_code == 200 + + assert events == [ + "base-before", + "derived-before", + "application-before", + "base-after", + "derived-after", + "application-after", + ] + + +@pytest.mark.asyncio +async def test_lifecycle_routes_cannot_be_overridden_by_subclasses() -> None: + events: list[str] = [] + + class EndpointOverrideHost(AgentServerHost): + async def _before_snapshot_endpoint(self, _request): + return JSONResponse({"overridden": True}, status_code=418) + + async def _after_restore_endpoint(self, _request): + return JSONResponse({"overridden": True}, status_code=418) + + @staticmethod + async def _read_json_object(_request): + raise AssertionError("subclasses must not replace framework parsing") + + @staticmethod + def _parse_session_context(_payload): + raise AssertionError("subclasses must not replace framework parsing") + + async def _before_snapshot(self) -> None: + events.append("before") + + async def _after_restore(self, context: AgentSessionContext) -> None: + events.append(context.session_id) + + agent = EndpointOverrideHost() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + before_response = await lifecycle_client.post( + "/_agent/before-snapshot", + json={}, + ) + after_response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(), + ) + + assert before_response.status_code == 200 + assert after_response.status_code == 200 + assert events == ["before", "session-1"] + + +@pytest.mark.asyncio +async def test_after_restore_resets_omitted_overrides_to_captured_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CAPTURED_VALUE", "captured") + monkeypatch.delenv("ADDED_VALUE", raising=False) + agent = AgentServerHost() + observed: list[tuple[str, str | None]] = [] + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + del context + observed.append( + ( + os.environ["CAPTURED_VALUE"], + os.environ.get("ADDED_VALUE"), + ) + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + first = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-1", + overrides={ + "CAPTURED_VALUE": "first-restore", + "ADDED_VALUE": "added", + }, + ), + ) + second = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(restore_id="restore-2"), + ) + os.environ["ADDED_VALUE"] = "application-change" + third = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(restore_id="restore-3"), + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert third.status_code == 200 + assert observed == [ + ("first-restore", "added"), + ("captured", None), + ("captured", None), + ] + assert os.environ["CAPTURED_VALUE"] == "captured" + assert "ADDED_VALUE" not in os.environ + + +@pytest.mark.asyncio +async def test_before_snapshot_refreshes_environment_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("UPDATED_VALUE", "captured") + monkeypatch.setenv("REMOVED_VALUE", "captured") + agent = AgentServerHost() + observed: list[tuple[str, str | None]] = [] + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + os.environ.pop("REMOVED_VALUE") + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + del context + observed.append( + ( + os.environ["UPDATED_VALUE"], + os.environ.get("REMOVED_VALUE"), + ) + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + first = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-1", + overrides={ + "UPDATED_VALUE": "snapshot-value", + "REMOVED_VALUE": "restored-value", + }, + ), + ) + before = await lifecycle_client.post("/_agent/before-snapshot", json={}) + second = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(restore_id="restore-2"), + ) + + assert first.status_code == 200 + assert before.status_code == 200 + assert second.status_code == 200 + assert observed == [ + ("snapshot-value", "restored-value"), + ("snapshot-value", None), + ] + + +@pytest.mark.asyncio +async def test_after_restore_hydrates_ambient_request_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + outer_context = FoundryAgentRequestContext( + call_id="call-1", + user_id="user-1", + session_id="captured-session", + ) + token = set_request_context(outer_context) + observed: list[tuple[str | None, str | None, str | None]] = [] + + class ExtendedAgentServerHost(AgentServerHost): + async def _after_restore(self, context: AgentSessionContext) -> None: + current = get_request_context() + observed.append((current.call_id, current.user_id, current.session_id)) + assert current.session_id == context.session_id + + agent = ExtendedAgentServerHost() + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + current = get_request_context() + observed.append((current.call_id, current.user_id, current.session_id)) + assert current.session_id == context.session_id + + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="restored-session"), + ) + + assert response.status_code == 200 + assert observed == [ + ("call-1", "user-1", "restored-session"), + ("call-1", "user-1", "restored-session"), + ] + assert get_request_context() is outer_context + finally: + reset_request_context(token) + + +@pytest.mark.asyncio +async def test_after_restore_retry_and_new_materialization_are_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + agent = AgentServerHost() + restores: list[tuple[str, str]] = [] + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + restores.append((context.restore_id, os.environ["RESTORE_VALUE"])) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + first = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-1", + overrides={"RESTORE_VALUE": "first"}, + ), + ) + retry = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-1", + overrides={"RESTORE_VALUE": "first"}, + ), + ) + later_restore = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-2", + overrides={"RESTORE_VALUE": "second"}, + ), + ) + delayed_retry = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + restore_id="restore-1", + overrides={"RESTORE_VALUE": "first"}, + ), + ) + + assert first.status_code == 200 + assert retry.status_code == 200 + assert later_restore.status_code == 200 + assert delayed_retry.status_code == 200 + assert restores == [ + ("restore-1", "first"), + ("restore-2", "second"), + ] + assert os.environ["RESTORE_VALUE"] == "second" + + +@pytest.mark.asyncio +async def test_after_restore_rejects_a_different_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + agent = AgentServerHost() + callback_count = 0 + + @agent.after_restore_handler + async def after_restore( + context: AgentSessionContext, + ) -> None: # pylint: disable=unused-argument + nonlocal callback_count + callback_count += 1 + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + assert ( + await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="session-1"), + ) + ).status_code == 200 + response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(session_id="session-2", restore_id="restore-2"), + ) + + assert response.status_code == 409 + assert response.json()["error"]["code"] == "session_mismatch" + assert callback_count == 1 + assert agent.config.session_id == "session-1" + + +@pytest.mark.asyncio +async def test_before_snapshot_retries_once_and_resets_after_restore( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + agent = AgentServerHost() + callback_count = 0 + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + nonlocal callback_count + callback_count += 1 + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + assert (await lifecycle_client.post("/_agent/before-snapshot", json={})).status_code == 200 + assert (await lifecycle_client.post("/_agent/before-snapshot", json={})).status_code == 200 + assert callback_count == 1 + + assert ( + await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(), + ) + ).status_code == 200 + assert (await lifecycle_client.post("/_agent/before-snapshot", json={})).status_code == 200 + + assert callback_count == 2 + + +@pytest.mark.asyncio +async def test_after_restore_failure_is_sanitized_and_rolls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "captured-session") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example") + monkeypatch.setenv("RESTORED_VALUE", "captured-value") + agent = AgentServerHost() + + @agent.after_restore_handler + async def after_restore( + context: AgentSessionContext, + ) -> None: # pylint: disable=unused-argument + raise RuntimeError("secret connection string") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + response = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + session_id="restored-session", + overrides={ + "FOUNDRY_PROJECT_ENDPOINT": "https://restored.example", + "RESTORED_VALUE": "restored-value", + }, + ), + ) + + assert response.status_code == 500 + assert response.json() == { + "error": { + "code": "after_restore_failed", + "message": "The after-restore hook failed.", + } + } + assert "secret" not in response.text + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == "captured-session" + assert os.environ["RESTORED_VALUE"] == "captured-value" + assert agent.config.session_id == "captured-session" + assert agent.config.project_endpoint == "https://captured.example" + + +@pytest.mark.asyncio +async def test_after_restore_cancellation_rolls_back_and_can_be_retried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "captured-session") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example") + monkeypatch.setenv("RESTORED_VALUE", "captured-value") + agent = AgentServerHost() + callback_started = asyncio.Event() + callback_count = 0 + + @agent.after_restore_handler + async def after_restore(context: AgentSessionContext) -> None: + nonlocal callback_count + callback_count += 1 + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == context.session_id + assert os.environ["RESTORED_VALUE"] == "restored-value" + assert agent.config.project_endpoint == "https://restored.example" + if callback_count == 1: + callback_started.set() + await asyncio.Event().wait() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + request_task = asyncio.create_task( + lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + session_id="restored-session", + overrides={ + "FOUNDRY_PROJECT_ENDPOINT": "https://restored.example", + "RESTORED_VALUE": "restored-value", + }, + ), + ) + ) + await callback_started.wait() + request_task.cancel() + with pytest.raises(asyncio.CancelledError): + await request_task + + assert os.environ["FOUNDRY_AGENT_SESSION_ID"] == "captured-session" + assert os.environ["RESTORED_VALUE"] == "captured-value" + assert agent.config.session_id == "captured-session" + assert agent.config.project_endpoint == "https://captured.example" + + retried = await lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload( + session_id="restored-session", + overrides={ + "FOUNDRY_PROJECT_ENDPOINT": "https://restored.example", + "RESTORED_VALUE": "restored-value", + }, + ), + ) + + assert retried.status_code == 200 + assert callback_count == 2 + + +@pytest.mark.asyncio +async def test_before_snapshot_failure_can_be_retried() -> None: + agent = AgentServerHost() + callback_count = 0 + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + nonlocal callback_count + callback_count += 1 + if callback_count == 1: + raise RuntimeError("secret failure") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + failed = await lifecycle_client.post("/_agent/before-snapshot", json={}) + retried = await lifecycle_client.post("/_agent/before-snapshot", json={}) + + assert failed.status_code == 500 + assert failed.json()["error"]["code"] == "before_snapshot_failed" + assert "secret" not in failed.text + assert retried.status_code == 200 + assert callback_count == 2 + + +@pytest.mark.asyncio +async def test_lifecycle_hooks_are_serialized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + agent = AgentServerHost() + before_started = asyncio.Event() + release_before = asyncio.Event() + after_called = asyncio.Event() + + @agent.before_snapshot_handler + async def before_snapshot() -> None: + before_started.set() + await release_before.wait() + + @agent.after_restore_handler + async def after_restore( + context: AgentSessionContext, + ) -> None: # pylint: disable=unused-argument + after_called.set() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + before_task = asyncio.create_task(lifecycle_client.post("/_agent/before-snapshot", json={})) + await before_started.wait() + after_task = asyncio.create_task( + lifecycle_client.post( + "/_agent/after-restore", + json=_after_restore_payload(), + ) + ) + await asyncio.sleep(0) + assert not after_called.is_set() + + release_before.set() + before_response, after_response = await asyncio.gather(before_task, after_task) + + assert before_response.status_code == 200 + assert after_response.status_code == 200 + assert after_called.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path", + [ + "/_agent/before-snapshot", + "/_before_snapshot", + ], +) +async def test_reserved_lifecycle_route_cannot_be_shadowed(path: str) -> None: + async def shadowed_route(_request): + return JSONResponse({"shadowed": True}, status_code=418) + + agent = AgentServerHost( + routes=[ + Route( + path, + shadowed_route, + methods=["POST"], + ) + ] + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=agent), + base_url="http://testserver", + ) as lifecycle_client: + response = await lifecycle_client.post(path, json={}) + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_agent_session_context_defensively_copies_overrides() -> None: + overrides = {"VALUE": "original"} + context = AgentSessionContext( + session_id="session-1", + restore_id="restore-1", + session_env_overrides=overrides, + ) + + overrides["VALUE"] = "changed" + + assert context.session_env_overrides == {"VALUE": "original"} + with pytest.raises(TypeError): + context.session_env_overrides["VALUE"] = "changed" # type: ignore[index] + + +def test_agent_session_context_repr_excludes_environment_overrides() -> None: + secret = "sentinel-secret-override" + context = AgentSessionContext( + session_id="session-1", + restore_id="restore-1", + session_env_overrides={"CONNECTION_STRING": secret}, + ) + + assert secret not in repr(context) + assert "session_env_overrides" not in repr(context) + + +def test_lifecycle_decorators_return_original_functions() -> None: + agent = AgentServerHost() + + async def before_snapshot() -> None: + pass + + async def after_restore(context: AgentSessionContext) -> None: + del context + + assert agent.before_snapshot_handler(before_snapshot) is before_snapshot + assert agent.after_restore_handler(after_restore) is after_restore + + +def test_lifecycle_routes_are_registered_before_user_routes() -> None: + agent = AgentServerHost() + + assert [route.path for route in agent.routes[:4]] == [ + "/_agent/before-snapshot", + "/_agent/after-restore", + "/_before_snapshot", + "/_after_restore", + ] + + +def test_agent_session_context_defaults_to_immutable_empty_overrides() -> None: + context = AgentSessionContext(session_id="session-1", restore_id="restore-1") + + assert context.session_env_overrides == {} + assert isinstance(context.session_env_overrides, MappingProxyType) diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_runtime_quiescence.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_runtime_quiescence.py new file mode 100644 index 000000000000..8b87d9d9e215 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_runtime_quiescence.py @@ -0,0 +1,1485 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Rollback must retain one identity and stop all task admission before quiescence.""" + +from __future__ import annotations + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from azure.ai.agentserver.core.tasks import RetryPolicy, TaskConflictError, TaskContext, multi_turn_task, task +from azure.ai.agentserver.core.tasks import _manager +from azure.ai.agentserver.core.tasks._exceptions import TaskDeferred +from azure.ai.agentserver.core.tasks._lease import derive_lease_owner +from azure.ai.agentserver.core.tasks._manager import TaskManager, get_task_manager +from azure.ai.agentserver.core.tasks._models import TaskPatchRequest +from test_snapshot_task_activation import client, host, isolate_runtime, payload, provider_factory, seed_expired_task + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("started", [False, True]) +async def test_agent_name_override_does_not_retain_incompatible_runtime( + monkeypatch, provider_factory, failure, started +): + provider, factory = provider_factory + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + running, finish, failing = asyncio.Event(), asyncio.Event(), asyncio.Event() + managers, final_agents = [], [] + app = host() + + @task(name="agent_identity_startup") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + try: + await finish.wait() + return ctx.input + finally: + final_agents.append(app.config.agent_name) + + @app.after_restore_handler + async def initialize(context): + managers.append(get_task_manager()) + if len(managers) == 1: + if started: + await startup.start(task_id="agent-startup", input=context.session_id) + await asyncio.wait_for(running.wait(), 2) + failing.set() + if failure == "exception": + raise RuntimeError("Fail the overridden agent startup") + await asyncio.Event().wait() + assert app.config.agent_name == "snapshot-agent" + + first = payload() + first["session_context"]["session_env_overrides"]["FOUNDRY_AGENT_NAME"] = "overridden-agent" + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=first)) + try: + await asyncio.wait_for(failing.wait(), 10) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert managers[0] is not managers[1] + assert managers[0]._shutdown_event.is_set() + assert not managers[0]._has_live_workers + assert managers[1]._lease_owner == derive_lease_owner("snapshot-agent", "session-1") + assert factory.call_count == 2 + assert final_agents == (["overridden-agent"] if started else []) + if started: + stored = await provider.get("agent-startup") + assert stored.agent_name == "overridden-agent" + assert stored.payload["input"] == "session-1" + assert stored.status == "in_progress" + assert stored.lease.owner == derive_lease_owner("overridden-agent", "session-1") + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + finish.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_live_agent_override_is_rejected_before_mutation(provider_factory): + provider, _ = provider_factory + app = host() + running, finish = asyncio.Event(), asyncio.Event() + callbacks = [] + + @task(name="live_agent_startup") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await finish.wait() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + callbacks.append(context.restore_id) + await startup.start(task_id="live-agent-startup", input=context.session_id) + await asyncio.wait_for(running.wait(), 2) + + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + manager = get_task_manager() + changed = payload(restore_id="restore-2") + changed["session_context"]["session_env_overrides"]["FOUNDRY_AGENT_NAME"] = "other-agent" + response = await http.post("/_agent/after-restore", json=changed) + assert response.status_code == 409 + assert callbacks == ["restore-1"] + assert os.environ["FOUNDRY_AGENT_NAME"] == app.config.agent_name == "snapshot-agent" + assert manager._materialization_restore_id == "restore-1" + assert manager._lease_owner == derive_lease_owner("snapshot-agent", "session-1") + stored = await provider.get("live-agent-startup") + assert stored.lease.owner == manager._lease_owner + finally: + finish.set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("guid", ["b" * 32, "", None], ids=["changed", "cleared", "captured-baseline"]) +async def test_live_guid_override_preserves_manager_and_worker_lease_identity(provider_factory, monkeypatch, guid): + provider, _ = provider_factory + running, finish = asyncio.Event(), asyncio.Event() + callbacks, renewal_identities, runs = [], [], [] + original_renewal = _manager.lease_renewal_loop + app = host() + + async def renewal(*args, **kwargs): + renewal_identities.append((kwargs["lease_owner"], kwargs["lease_instance_id"])) + await original_renewal(*args, **kwargs) + + monkeypatch.setattr(_manager, "lease_renewal_loop", renewal) + + @task(name="live_guid_startup") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await finish.wait() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + callbacks.append(context.restore_id) + runs.append(await startup.start(task_id="live-guid-startup", input=context.session_id)) + await asyncio.wait_for(running.wait(), 2) + + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + manager = get_task_manager() + identity = (manager._lease_owner, manager._instance_id) + worker = manager._active_tasks["live-guid-startup"] + changed = payload(restore_id="restore-2") + overrides = changed["session_context"]["session_env_overrides"] + if guid is None: + overrides.pop("FOUNDRY_AGENT_SESSION_GUID") + else: + overrides["FOUNDRY_AGENT_SESSION_GUID"] = guid + response = await http.post("/_agent/after-restore", json=changed) + assert response.status_code == 409, response.text + assert response.json()["error"]["code"] == "session_guid_mismatch" + assert get_task_manager() is manager + assert callbacks == ["restore-1"] + assert manager._materialization_restore_id == "restore-1" + assert (manager._lease_owner, manager._instance_id) == identity + assert renewal_identities == [identity] + assert manager._active_tasks["live-guid-startup"] is worker + assert not worker.execution_task.done() + assert not worker.renewal_task.done() + assert os.environ["FOUNDRY_AGENT_SESSION_GUID"] == app.config.session_guid == "a" * 32 + stored = await provider.get("live-guid-startup") + assert (stored.lease.owner, stored.lease.instance_id) == identity + finally: + finish.set() + await asyncio.wait_for(asyncio.gather(*(run.result() for run in runs), return_exceptions=True), 2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("work", ["running", "admitted"]) +@pytest.mark.parametrize("duplicate", [False, True]) +async def test_compatible_restore_preserves_owned_task_lease_identity(provider_factory, monkeypatch, work, duplicate): + provider, factory = provider_factory + app = host() + written, release, running, finish = (asyncio.Event() for _ in range(4)) + callbacks, renewal_identities = [], [] + original_create = provider.create + original_renewal = _manager.lease_renewal_loop + + async def create(request): + stored = await original_create(request) + if work == "admitted": + written.set() + await release.wait() + return stored + + async def renewal(*args, **kwargs): + renewal_identities.append((kwargs["lease_owner"], kwargs["lease_instance_id"])) + await original_renewal(*args, **kwargs) + + monkeypatch.setattr(provider, "create", create) + monkeypatch.setattr(_manager, "lease_renewal_loop", renewal) + + @task(name="compatible_restore") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await finish.wait() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + callbacks.append(context.restore_id) + + producer = None + run = None + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + manager = get_task_manager() + identity = (manager._lease_owner, manager._instance_id) + scanner = manager._periodic_recovery_task + producer = asyncio.create_task(startup.start(task_id="compatible-restore", input="saved")) + if work == "admitted": + await asyncio.wait_for(written.wait(), 2) + assert manager._active_admissions + assert manager._admitted_task_ids["compatible-restore"] > 0 + assert not manager._active_tasks + else: + run = await asyncio.wait_for(producer, 2) + await asyncio.wait_for(running.wait(), 2) + changed = payload(restore_id="restore-2") + requests = [http.post("/_agent/after-restore", json=changed)] + if duplicate: + requests.append(http.post("/_agent/after-restore", json=changed)) + responses = await asyncio.gather(*requests) + assert all(response.status_code == 200 for response in responses) + assert get_task_manager() is manager + assert factory.call_count == 1 + assert callbacks == ["restore-1", "restore-2"] + assert manager._materialization_restore_id == "restore-2" + assert (manager._lease_owner, manager._instance_id) == identity + assert manager._periodic_recovery_task is scanner + release.set() + run = await asyncio.wait_for(producer, 2) + await asyncio.wait_for(running.wait(), 2) + assert not manager._admitted_task_ids + assert renewal_identities == [identity] + await manager._provider_update_locked( + "compatible-restore", + TaskPatchRequest( + payload={"probe": "after-compatible-restore"}, + **manager._lease_ext_kwargs("compatible-restore"), + ), + ) + stored = await provider.get("compatible-restore") + assert (stored.lease.owner, stored.lease.instance_id) == identity + assert stored.payload["input"] == "saved" + assert stored.payload["probe"] == "after-compatible-restore" + finally: + release.set() + finish.set() + if producer is not None: + result = await asyncio.wait_for(asyncio.gather(producer, return_exceptions=True), 2) + if run is None and not isinstance(result[0], BaseException): + run = result[0] + if run is not None: + await asyncio.wait_for(asyncio.gather(run.result(), return_exceptions=True), 2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api", ["start", "run", "get-active", "manager-keyword", "recovery"]) +async def test_inline_claim_registration_is_excluded_from_recovery(provider_factory, monkeypatch, api): + provider, _ = provider_factory + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(manager) + claimed, release, running, finish = (asyncio.Event() for _ in range(4)) + observed = [] + blocked = False + original_update = provider.update + + @task(name="inline_claim_registration") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + observed.append(ctx.input) + running.set() + await finish.wait() + return ctx.input["value"] + + async def update(task_id, patch): + nonlocal blocked + result = await original_update(task_id, patch) + if not blocked and patch.lease_instance_id == manager._instance_id and patch.lease_duration_seconds: + blocked = True + claimed.set() + await release.wait() + return result + + await manager.startup(restore_id="restore-1") + await seed_expired_task(provider, existing.name) + monkeypatch.setattr(provider, "update", update) + if api == "start": + operation = existing.start(task_id="existing-task", input={"value": "saved-input"}) + elif api == "run": + operation = existing.run(task_id="existing-task", input={"value": "saved-input"}) + elif api == "get-active": + operation = existing.get_active_run("existing-task") + elif api == "recovery": + operation = manager._recover_stale_tasks() + else: + operation = manager.get_active_run(task_id="existing-task") + inline = asyncio.create_task(operation) + recovery = None + run = None + try: + await asyncio.wait_for(claimed.wait(), 2) + stored = await provider.get("existing-task") + assert stored.lease.instance_id == manager._instance_id + assert not manager._active_tasks + recovery = asyncio.create_task(manager._recover_stale_tasks()) + await asyncio.wait_for(asyncio.shield(recovery), 1) + assert manager._admitted_task_ids["existing-task"] > 0 + assert observed == [] + release.set() + if api not in ("run", "recovery"): + run = await asyncio.wait_for(inline, 2) + assert run is not None + await asyncio.wait_for(running.wait(), 2) + if api == "recovery": + await asyncio.wait_for(inline, 2) + run = await manager.get_active_run(task_id="existing-task") + assert run is not None + await manager._recover_stale_tasks() + assert observed == [{"value": "saved-input"}] + assert not manager._admitted_task_ids + finish.set() + if api == "run": + assert await asyncio.wait_for(inline, 2) == "saved-input" + else: + assert await asyncio.wait_for(run.result(), 2) == "saved-input" + finally: + release.set() + finish.set() + pending = [inline] + ([recovery] if recovery is not None else []) + results = await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 2) + if api != "run" and not isinstance(results[0], BaseException) and results[0] is not None: + await asyncio.wait_for(asyncio.gather(results[0].result(), return_exceptions=True), 2) + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api", ["get-active", "start", "run", "cancelled-get-active"]) +async def test_recovery_claim_registration_excludes_other_producers(provider_factory, monkeypatch, api): + provider, _ = provider_factory + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(manager) + claimed, release, running, finish = (asyncio.Event() for _ in range(4)) + observed = [] + claims = 0 + original_update = provider.update + + @task(name="recovery_first_registration") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + observed.append(ctx.input) + running.set() + await finish.wait() + return ctx.input["value"] + + async def update(task_id, patch): + nonlocal claims + result = await original_update(task_id, patch) + if patch.lease_instance_id == manager._instance_id and patch.lease_duration_seconds: + claims += 1 + if claims == 1: + claimed.set() + await release.wait() + return result + + await manager.startup(restore_id="restore-1") + await seed_expired_task(provider, existing.name) + monkeypatch.setattr(provider, "update", update) + recovery = asyncio.create_task(manager._recover_stale_tasks()) + follower = None + run = None + try: + await asyncio.wait_for(claimed.wait(), 10) + if api == "start": + operation = existing.start(task_id="existing-task", input={"value": "saved-input"}) + elif api == "run": + operation = existing.run(task_id="existing-task", input={"value": "saved-input"}) + else: + operation = manager.get_active_run(task_id="existing-task") + follower = asyncio.create_task(operation) + # Let the other producer observe the committed claim before its response is released. + await asyncio.sleep(0) + if api == "cancelled-get-active": + follower.cancel() + with pytest.raises(asyncio.CancelledError): + await follower + assert follower not in manager._active_admissions + release.set() + await asyncio.wait_for(recovery, 10) + await asyncio.wait_for(running.wait(), 10) + assert claims == 1 + assert observed == [{"value": "saved-input"}] + if api in {"start", "run"}: + with pytest.raises(TaskConflictError): + await asyncio.wait_for(follower, 10) + run = await manager.get_active_run(task_id="existing-task") + elif api == "cancelled-get-active": + run = await manager.get_active_run(task_id="existing-task") + else: + run = await asyncio.wait_for(follower, 10) + assert run is not None + finish.set() + assert await asyncio.wait_for(run.result(), 10) == "saved-input" + finally: + release.set() + finish.set() + pending = [recovery] + ([follower] if follower is not None else []) + results = await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 10) + if api in {"get-active", "start"} and len(results) > 1 and not isinstance(results[1], BaseException): + if results[1] is not None: + await asyncio.wait_for(asyncio.gather(results[1].result(), return_exceptions=True), 10) + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("preserve", [False, True]) +async def test_shutdown_interrupts_blocked_periodic_recovery_list(provider_factory, monkeypatch, preserve): + provider, _ = provider_factory + monkeypatch.setattr(_manager, "_PERIODIC_RECOVERY_INTERVAL_SECONDS", 0.01) + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + blocked, release, cancelled = (asyncio.Event() for _ in range(3)) + original_list = provider.list + calls = 0 + + async def list_tasks(**kwargs): + nonlocal calls + calls += 1 + if calls > 1: + blocked.set() + try: + await release.wait() + except asyncio.CancelledError: + cancelled.set() + raise + return await original_list(**kwargs) + + monkeypatch.setattr(provider, "list", list_tasks) + shutdown = None + try: + await manager.startup(restore_id="restore-1") + scanner = manager._periodic_recovery_task + await asyncio.wait_for(blocked.wait(), 2) + shutdown = asyncio.create_task(manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=preserve)) + await asyncio.wait_for(asyncio.shield(shutdown), 1) + assert cancelled.is_set() + assert scanner.done() + assert manager._periodic_recovery_task is None + assert manager._admissions_drained.is_set() + assert not manager._active_admissions + assert not manager._has_live_workers + finally: + release.set() + if shutdown is not None: + await asyncio.wait_for(asyncio.gather(shutdown, return_exceptions=True), 2) + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=preserve) + + +@pytest.mark.asyncio +async def test_host_shutdown_interrupts_blocked_initial_recovery_list(provider_factory): + provider, _ = provider_factory + app = host() + blocked, release, cancelled, started, stop = (asyncio.Event() for _ in range(5)) + original_list = provider.list + + async def list_tasks(**kwargs): + blocked.set() + try: + await release.wait() + except asyncio.CancelledError: + cancelled.set() + raise + return await original_list(**kwargs) + + provider.list = list_tasks + + # Enter and leave the lifespan in one task, as an ASGI server does. + async def run_host(): + async with app.router.lifespan_context(app): + started.set() + await stop.wait() + + host_task = asyncio.create_task(run_host()) + restore = None + try: + await asyncio.wait_for(started.wait(), 2) + async with client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + await asyncio.wait_for(blocked.wait(), 2) + stop.set() + await asyncio.wait_for(asyncio.shield(host_task), 2) + assert cancelled.is_set() + assert app._task_manager is None + finally: + release.set() + stop.set() + pending = [task for task in (host_task, restore) if task is not None] + await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("task_kind", ["one_shot", "multi_turn"]) +@pytest.mark.parametrize("cooperative", [False, True]) +async def test_host_teardown_joins_workers_while_restore_rollback_is_pending(provider_factory, task_kind, cooperative): + provider, _ = provider_factory + app = host() + started, cleanup_started, release_cleanup, release_restore, hosted, stop, recovered = ( + asyncio.Event() for _ in range(7) + ) + observed = {} + runs, restores = [], [] + saved_input = {"value": "original-input", "session_id": "session-1"} + decorate = task if task_kind == "one_shot" else multi_turn_task + + @decorate(name="teardown_overlap_job") + async def job(ctx: TaskContext[dict]) -> dict: + if ctx.entry_mode == "recovered": + observed["recovered_input"] = ctx.input + recovered.set() + return ctx.input + observed["start"] = app.config.session_id + started.set() + try: + if cooperative: + await ctx.shutdown.wait() + raise asyncio.CancelledError + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_started.set() + await release_cleanup.wait() + observed["cleanup"] = app.config.session_id + raise + return ctx.input + + if cooperative: + app._graceful_shutdown_timeout = 10 + + @app.shutdown_handler + async def shutdown(): + if len(restores) != 1: + return + manager = get_task_manager() + worker = manager._active_tasks["teardown-overlap"].execution_task + manager._signal_shutdown() + # Protocol shutdown can finish a worker before serialized manager cleanup begins. + await worker + + @app.after_restore_handler + async def initialize(context): + restores.append(context.restore_id) + if len(restores) == 1: + runs.append(await job.start(task_id="teardown-overlap", input=saved_input)) + await asyncio.wait_for(started.wait(), 10) + await release_restore.wait() + raise RuntimeError("fail restore after submitting work") + + # Enter and leave the lifespan in one task, as an ASGI server does. + async def run_host(): + async with app.router.lifespan_context(app): + hosted.set() + await stop.wait() + + host_task = asyncio.create_task(run_host()) + restore = None + try: + await asyncio.wait_for(hosted.wait(), 10) + async with client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + await asyncio.wait_for(started.wait(), 10) + stop.set() + await asyncio.wait_for(cleanup_started.wait(), 10) + await asyncio.sleep(0.1) + # Teardown must not return while a cancelled worker still cleans up under the restored binding. + assert not host_task.done() + release_cleanup.set() + await asyncio.wait_for(host_task, 10) + release_restore.set() + assert (await asyncio.wait_for(restore, 10)).status_code == 500 + assert observed == {"start": "session-1", "cleanup": "session-1"} + assert app.config.session_id == "" + stored = await provider.get("teardown-overlap") + assert stored is not None + assert stored.status == "in_progress" + assert stored.payload["input"] == saved_input + with pytest.raises(TaskDeferred): + await runs[0].result() + async with app.router.lifespan_context(app), client(app) as http: + response = await http.post("/_agent/after-restore", json=payload(restore_id="restore-2")) + assert response.status_code == 200, response.text + await asyncio.wait_for(recovered.wait(), 10) + active = get_task_manager()._active_tasks.get("teardown-overlap") + if active is not None: + await asyncio.wait_for(asyncio.shield(active.execution_task), 10) + assert observed["recovered_input"] == saved_input + assert restores == ["restore-1", "restore-2"] + finally: + release_cleanup.set() + release_restore.set() + stop.set() + pending = [task_ for task_ in (host_task, restore) if task_ is not None] + await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 10) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("inner_id", ["outer", "inner"]) +async def test_task_registration_admission_cleans_up_nested_failure(provider_factory, inner_id): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + with manager._task_admission(task_id="outer"): + with pytest.raises(RuntimeError, match="nested failure"): + with manager._task_admission(task_id=inner_id): + assert manager._admitted_task_ids == ({"outer": 2} if inner_id == "outer" else {"outer": 1, "inner": 1}) + raise RuntimeError("nested failure") + assert manager._admitted_task_ids == {"outer": 1} + assert not manager._admissions_drained.is_set() + assert not manager._admitted_task_ids + assert not manager._active_admissions + assert manager._admissions_drained.is_set() + await manager.shutdown(wait_for_workers=True) + + +@pytest.mark.asyncio +async def test_compatible_restore_without_owned_task_work_rotates_lease_identity(provider_factory): + _, factory = provider_factory + app = host() + async with app.router.lifespan_context(app), client(app) as http: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + manager = get_task_manager() + await asyncio.wait_for(manager._admissions_drained.wait(), 2) + instance = manager._instance_id + scanner = manager._periodic_recovery_task + assert scanner is not None and not scanner.done() + assert not manager._active_tasks + assert all(worker is scanner or worker.done() for worker in manager._owned_workers) + response = await http.post("/_agent/after-restore", json=payload(restore_id="restore-2")) + assert response.status_code == 200, response.text + assert get_task_manager() is manager + assert manager._instance_id != instance + assert manager._materialization_restore_id == "restore-2" + assert manager._periodic_recovery_task is scanner + assert factory.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_change", ["endpoint", "backend", "state_root", "home"]) +async def test_live_provider_override_preserves_manager_and_worker_lease_identity( + provider_factory, monkeypatch, tmp_path, provider_change +): + provider, factory = provider_factory + backend = "local" if provider_change in ("state_root", "home") else "hosted" + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", backend) + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example.invalid/project") + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path / "captured")) + if provider_change == "home": + monkeypatch.setattr(Path, "home", classmethod(lambda cls: Path(os.environ["HOME"]))) + monkeypatch.setenv("HOME", str(tmp_path / "captured-home")) + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", "") + key, value = { + "endpoint": ("FOUNDRY_PROJECT_ENDPOINT", "https://other.example.invalid/project"), + "backend": ("AGENTSERVER_TASKS_BACKEND", "local"), + "state_root": ("AGENTSERVER_STATE_ROOT", str(tmp_path / "other-root")), + "home": ("HOME", str(tmp_path / "other-home")), + }[provider_change] + original_value = os.environ[key] + running, finish = asyncio.Event(), asyncio.Event() + callbacks, renewal_identities, runs = [], [], [] + original_renewal = _manager.lease_renewal_loop + app = host() + + async def renewal(*args, **kwargs): + renewal_identities.append((kwargs["lease_owner"], kwargs["lease_instance_id"])) + await original_renewal(*args, **kwargs) + + monkeypatch.setattr(_manager, "lease_renewal_loop", renewal) + + @task(name="live_provider_startup") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await finish.wait() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + callbacks.append(context.restore_id) + runs.append(await startup.start(task_id="live-provider-startup", input=context.session_id)) + await asyncio.wait_for(running.wait(), 2) + + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + manager = get_task_manager() + identity = (manager._lease_owner, manager._instance_id) + binding = manager._provider_binding + worker = manager._active_tasks["live-provider-startup"] + original_config = vars(app.config).copy() + changed = payload(restore_id="restore-2") + changed["session_context"]["session_env_overrides"][key] = value + response = await http.post("/_agent/after-restore", json=changed) + assert response.status_code == 500, response.text + assert response.json()["error"]["code"] == "after_restore_failed" + assert get_task_manager() is manager + assert manager.provider is provider + assert factory.call_count == 1 + assert callbacks == ["restore-1"] + assert manager._materialization_restore_id == "restore-1" + assert (manager._lease_owner, manager._instance_id) == identity + assert renewal_identities == [identity] + assert manager._provider_binding == binding + assert manager._provider_configuration_matches() + assert manager._active_tasks["live-provider-startup"] is worker + assert not worker.execution_task.done() + assert not worker.renewal_task.done() + assert vars(app.config) == original_config + assert os.environ[key] == original_value + await manager._provider_update_locked( + "live-provider-startup", + TaskPatchRequest( + payload={"probe": "after-rejection"}, + **manager._lease_ext_kwargs("live-provider-startup"), + ), + ) + stored = await provider.get("live-provider-startup") + assert (stored.lease.owner, stored.lease.instance_id) == identity + assert stored.payload["input"] == "session-1" + assert stored.payload["probe"] == "after-rejection" + finally: + finish.set() + await asyncio.wait_for(asyncio.gather(*(run.result() for run in runs), return_exceptions=True), 2) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("api", ["handle", "run"]) +@pytest.mark.parametrize("kind", ["one-shot", "multi-turn"]) +async def test_failed_restore_before_handler_defers_accepted_callers(provider_factory, monkeypatch, failure, api, kind): + provider, _ = provider_factory + app = host() + blocked, setup_cancelled, failing, recovered = (asyncio.Event() for _ in range(4)) + managers, waiters, observed = [], [], [] + original_read = TaskManager._provider_get_tracked + decorate = task if kind == "one-shot" else multi_turn_task + + async def read(manager, task_id): + active = manager._active_tasks.get(task_id) + if managers and manager is managers[0] and active is not None: + if active.execution_task is asyncio.current_task(): + blocked.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + setup_cancelled.set() + raise + return await original_read(manager, task_id) + + monkeypatch.setattr(TaskManager, "_provider_get_tracked", read) + + @decorate(name="initial_setup_rollback") + async def startup(ctx: TaskContext[str]) -> str: + observed.append((ctx.input, ctx.entry_mode)) + recovered.set() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + managers.append(get_task_manager()) + if len(managers) != 1: + return + if api == "handle": + run = await startup.start(task_id="initial-setup", input=context.session_id) + waiters.append(asyncio.create_task(run.result())) + else: + waiters.append(asyncio.create_task(startup.run(task_id="initial-setup", input=context.session_id))) + await asyncio.wait_for(blocked.wait(), 2) + failing.set() + if failure == "exception": + raise RuntimeError("Fail restore before the accepted task enters its handler") + await asyncio.Event().wait() + + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(failing.wait(), 2) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + assert setup_cancelled.is_set() + assert not managers[0]._has_live_workers + assert observed == [] + with pytest.raises(TaskDeferred): + await asyncio.wait_for(waiters[0], 2) + stored = await provider.get("initial-setup") + assert stored.status == "in_progress" + assert stored.payload["input"] == "session-1" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + await asyncio.wait_for(recovered.wait(), 2) + assert managers[0] is not managers[1] + assert observed == [("session-1", "recovered")] + active = managers[1]._active_tasks.get("initial-setup") + if active is not None: + await asyncio.wait_for(asyncio.shield(active.execution_task), 2) + finally: + if not request.done(): + request.cancel() + for waiter in waiters: + if not waiter.done(): + waiter.cancel() + await asyncio.gather(request, *waiters, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("boundary", ["final-patch", "watchdog"]) +async def test_shutdown_after_steering_promotion_does_not_dispatch_handler(provider_factory, monkeypatch, boundary): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=1) + _manager.set_task_manager(manager) + running, finish_turn, blocked, release = (asyncio.Event() for _ in range(4)) + observed = [] + original_update = manager._provider_update_locked + original_watchdog = manager._spawn_watchdog_for_turn + + async def update(task_id, patch): + steering = (patch.payload or {}).get("steering", {}) + if ( + boundary == "final-patch" + and steering.get("drain_in_progress") is False + and (patch.payload or {}).get("input") == "next" + ): + blocked.set() + await release.wait() + return await original_update(task_id, patch) + + async def watchdog(*, task_id, opts, ctx): + if boundary == "watchdog" and ctx.is_steered_turn: + blocked.set() + await release.wait() + await original_watchdog(task_id=task_id, opts=opts, ctx=ctx) + + monkeypatch.setattr(manager, "_provider_update_locked", update) + monkeypatch.setattr(manager, "_spawn_watchdog_for_turn", watchdog) + + @multi_turn_task(name="steering_dispatch_boundary", steerable=True) + async def step(ctx: TaskContext[str]) -> str: + observed.append(ctx.input) + if ctx.input == "first": + running.set() + await finish_turn.wait() + return ctx.input + + shutdown = None + try: + first = await step.start(task_id="dispatch-boundary", input="first") + await asyncio.wait_for(running.wait(), 2) + queued = await step.start(task_id="dispatch-boundary", input="next") + finish_turn.set() + await asyncio.wait_for(blocked.wait(), 2) + shutdown = asyncio.create_task(manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True)) + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + release.set() + await asyncio.wait_for(shutdown, 2) + assert observed == ["first"] + assert await first.result() == "first" + with pytest.raises(TaskDeferred): + await asyncio.wait_for(queued.result(), 2) + stored = await provider.get("dispatch-boundary") + assert stored.status == "in_progress" + assert stored.payload["input"] == "next" + assert stored.payload["steering"]["pending_inputs"] == [] + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + assert not manager._pending_steering_futures + assert not manager._active_generation_future + assert not manager._has_live_workers + finally: + finish_turn.set() + release.set() + if shutdown is not None: + await asyncio.gather(shutdown, return_exceptions=True) + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("api", ["handle", "run"]) +async def test_failed_restore_defers_queued_steering_callers_before_retry(provider_factory, monkeypatch, failure, api): + provider, _ = provider_factory + monkeypatch.setenv("AGENTSERVER_SHUTDOWN_GRACE_SECONDS", "0.5") + app = host() + running, queued, failing, recovered = (asyncio.Event() for _ in range(4)) + managers, waiters, observed = [], [], [] + original_update = provider.update + + async def update(task_id, patch): + result = await original_update(task_id, patch) + if (patch.payload or {}).get("steering", {}).get("pending_inputs"): + queued.set() + return result + + monkeypatch.setattr(provider, "update", update) + + @multi_turn_task(name="rollback_queued_caller", steerable=True) + async def step(ctx: TaskContext[str]) -> str: + observed.append(ctx.input) + if ctx.input == "first": + running.set() + await ctx.shutdown.wait() + else: + recovered.set() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + managers.append(get_task_manager()) + if len(managers) != 1: + return + await step.start(task_id="queued-caller", input="first") + await asyncio.wait_for(running.wait(), 2) + if api == "handle": + run = await step.start(task_id="queued-caller", input="next") + waiters.append(asyncio.create_task(run.result())) + else: + waiters.append(asyncio.create_task(step.run(task_id="queued-caller", input="next"))) + await asyncio.wait_for(queued.wait(), 2) + failing.set() + if failure == "exception": + raise RuntimeError("Fail restore after accepting a queued steering input") + await asyncio.Event().wait() + + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(failing.wait(), 10) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + with pytest.raises(TaskDeferred): + await asyncio.wait_for(waiters[0], 2) + assert observed == ["first"] + assert not managers[0]._pending_steering_futures + assert not managers[0]._active_generation_future + stored = await provider.get("queued-caller") + assert stored.status == "in_progress" + assert stored.payload["input"] == "next" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + await asyncio.wait_for(recovered.wait(), 2) + assert managers[0] is not managers[1] + assert observed == ["first", "next"] + finally: + if not request.done(): + request.cancel() + for waiter in waiters: + if not waiter.done(): + waiter.cancel() + await asyncio.gather(request, *waiters, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("preserve", [False, True]) +async def test_shutdown_settles_only_unfinished_steering_futures(provider_factory, preserve): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + queued = manager._register_steering_future("queued") + completed = manager._register_steering_future("queued") + completed.set_result("already completed") + cancelled = manager._register_steering_future("queued") + cancelled.cancel() + promoted = asyncio.get_running_loop().create_future() + manager._active_generation_future["promoted"] = promoted + manager._active_generation_future["queued"] = queued + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=preserve) + for future in (queued, promoted): + assert future.done() + with pytest.raises(TaskDeferred): + await future + assert await completed == "already completed" + assert cancelled.cancelled() + assert not manager._pending_steering_futures + assert not manager._active_generation_future + + +@pytest.mark.asyncio +@pytest.mark.parametrize("root", [None, ""]) +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("started", [False, True]) +async def test_default_local_home_change_rebuilds_runtime(monkeypatch, tmp_path, root, failure, started): + # Exercise Linux HOME semantics on Windows as well. + monkeypatch.setattr(Path, "home", classmethod(lambda cls: Path(os.environ["HOME"]))) + monkeypatch.setenv("HOME", str(tmp_path / "captured-home")) + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local") + if root is None: + monkeypatch.delenv("AGENTSERVER_STATE_ROOT", raising=False) + else: + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", root) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + app = host() + running, finish, failing = asyncio.Event(), asyncio.Event(), asyncio.Event() + managers = [] + + @task(name="home_root_startup") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await finish.wait() + return ctx.input + + @app.after_restore_handler + async def initialize(context): + managers.append(get_task_manager()) + if len(managers) == 1: + if started: + await startup.start(task_id="home-startup", input=context.session_id) + await asyncio.wait_for(running.wait(), 2) + failing.set() + if failure == "exception": + raise RuntimeError("Fail the overridden HOME startup") + await asyncio.Event().wait() + assert get_task_manager().provider._base_dir == tmp_path / "captured-home" / ".agentserver" / "tasks" + + first = payload() + first["session_context"]["session_env_overrides"]["HOME"] = str(tmp_path / "overridden-home") + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=first)) + try: + await asyncio.wait_for(failing.wait(), 2) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert managers[0] is not managers[1] + assert not managers[0]._has_live_workers + assert managers[0]._shutdown_event.is_set() + if started: + stored = await managers[0].provider.get("home-startup") + assert stored.status == "in_progress" + assert stored.payload["input"] == "session-1" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + finish.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +async def test_child_submission_from_cancelled_finally_cannot_escape_rollback(provider_factory, failure): + provider, _ = provider_factory + app = host() + running, finish = asyncio.Event(), asyncio.Event() + child_ran = asyncio.Event() + runs, managers = [], [] + callbacks = 0 + + @task(name="shutdown_child") + async def child(ctx: TaskContext[str]) -> str: + child_ran.set() + await finish.wait() + return ctx.input + + @task(name="shutdown_parent") + async def parent(ctx: TaskContext[str]) -> str: + running.set() + try: + await finish.wait() + return ctx.input + finally: + if ctx.entry_mode == "fresh": + await child.start(task_id="escaping-child", input="must not run") + + @app.after_restore_handler + async def initialize(context): + nonlocal callbacks + callbacks += 1 + managers.append(get_task_manager()) + if callbacks == 1: + runs.append(await parent.start(task_id="shutdown-parent", input=context.session_id)) + await asyncio.wait_for(running.wait(), 2) + if failure == "exception": + raise RuntimeError("Trigger parent cancellation cleanup") + await asyncio.Event().wait() + else: + runs.append(await parent.get_active_run("shutdown-parent")) + + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(running.wait(), 2) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + with pytest.raises(TaskDeferred): + await runs[0].result() + assert not child_ran.is_set() + assert await provider.get("escaping-child") is None + assert not managers[0]._has_live_workers + stored = await provider.get("shutdown-parent") + assert stored.status == "in_progress" + assert stored.payload["input"] == "session-1" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + running.clear() + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + await asyncio.wait_for(running.wait(), 2) + assert managers[0] is not managers[1] + assert not child_ran.is_set() + finally: + finish.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + for manager in managers: + workers = set(manager._owned_workers) + for worker in workers: + if not worker.done(): + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api", ["task", "manager"]) +async def test_shutdown_waits_for_accepted_submission_before_snapshot(provider_factory, api): + provider, _ = provider_factory + written, release = asyncio.Event(), asyncio.Event() + original_create = provider.create + app = host() + manager = TaskManager(config=app.config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(manager) + + @task(name="inflight_submission") + async def startup(ctx: TaskContext[str]) -> str: + await ctx.shutdown.wait() + raise asyncio.CancelledError + + async def create(request): + result = await original_create(request) + written.set() + await release.wait() + return result + + provider.create = create + if api == "task": + operation = startup.start(task_id="inflight-startup", input="saved") + else: + operation = manager.create_and_start( + fn=startup._fn, + fn_name=startup.name, + task_id="inflight-startup", + input_val="saved", + input_type=str, + session_id=None, + title="inflight", + tags={}, + opts=startup._opts, + ) + submission = asyncio.create_task(operation) + shutdown = None + try: + await asyncio.wait_for(written.wait(), 2) + shutdown = asyncio.create_task(manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True)) + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + await asyncio.sleep(0) + assert not shutdown.done() + with pytest.raises(RuntimeError, match="shutting down"): + await startup.start(task_id="rejected-startup", input="must not run") + assert await provider.get("rejected-startup") is None + release.set() + run = await asyncio.wait_for(submission, 2) + await asyncio.wait_for(shutdown, 2) + with pytest.raises(TaskDeferred): + await run.result() + assert not manager._has_live_workers + stored = await provider.get("inflight-startup") + assert stored.status == "in_progress" + assert stored.payload["input"] == "saved" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + release.set() + await asyncio.gather(submission, return_exceptions=True) + if shutdown is not None: + await asyncio.gather(shutdown, return_exceptions=True) + await manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True) + + +@pytest.mark.asyncio +async def test_child_task_does_not_inherit_admission_from_its_producer(provider_factory): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(manager) + + @task(name="admission_inheritance") + async def startup(ctx: TaskContext[str]) -> str: + pytest.fail("A new child task must acquire its own admission") + + with manager._task_admission(): + shutdown = asyncio.create_task(manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True)) + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + submission = asyncio.create_task(startup.start(task_id="unaccepted-child", input="denied")) + with pytest.raises(RuntimeError, match="shutting down"): + await submission + assert not shutdown.done() + await asyncio.wait_for(shutdown, 2) + assert await provider.get("unaccepted-child") is None + assert not manager._active_admissions + + +@pytest.mark.asyncio +async def test_shutdown_does_not_wait_for_a_run_callers_result(provider_factory): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(manager) + running = asyncio.Event() + + @task(name="admission_run_wait") + async def startup(ctx: TaskContext[str]) -> str: + running.set() + await ctx.shutdown.wait() + raise asyncio.CancelledError + + request = asyncio.create_task(startup.run(task_id="run-wait", input="saved")) + await asyncio.wait_for(running.wait(), 2) + await asyncio.wait_for(manager.shutdown(wait_for_workers=True, preserve_recoverable_tasks=True), 2) + with pytest.raises(TaskDeferred): + await request + assert not manager._active_admissions + stored = await provider.get("run-wait") + assert stored.status == "in_progress" + assert stored.payload["input"] == "saved" + + +@pytest.mark.asyncio +async def test_rollback_waits_for_inflight_submission_before_restoring_config(provider_factory, monkeypatch): + provider, _ = provider_factory + original_create = provider.create + written, release = asyncio.Event(), asyncio.Event() + app = host() + submissions, managers = [], [] + + @task(name="rollback_inflight") + async def startup(ctx: TaskContext[str]) -> str: + await ctx.shutdown.wait() + raise asyncio.CancelledError + + async def create(request): + result = await original_create(request) + written.set() + await release.wait() + return result + + monkeypatch.setattr(provider, "create", create) + + @app.after_restore_handler + async def initialize(context): + managers.append(get_task_manager()) + submissions.append(asyncio.create_task(startup.start(task_id="rollback-inflight", input=context.session_id))) + await asyncio.wait_for(written.wait(), 2) + raise RuntimeError("Fail while a submission is still settling") + + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(written.wait(), 2) + await asyncio.wait_for(managers[0]._shutdown_event.wait(), 2) + assert not request.done() + assert app.config.session_id == "session-1" + release.set() + assert (await asyncio.wait_for(request, 2)).status_code == 500 + run = await asyncio.wait_for(submissions[0], 2) + with pytest.raises(TaskDeferred): + await run.result() + assert app.config.session_id == "" + assert not managers[0]._has_live_workers + stored = await provider.get("rollback-inflight") + assert stored.payload["input"] == "session-1" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + release.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, *submissions, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_ordinary_shutdown_retry_lets_handler_defer_for_recovery(provider_factory): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=5) + _manager.set_task_manager(manager) + first_attempt, fail = asyncio.Event(), asyncio.Event() + attempts = [] + + @task( + name="ordinary_retry_deferral", + retry=RetryPolicy(max_attempts=3, initial_delay=timedelta(0), jitter=False), + ) + async def flaky(ctx: TaskContext[str]) -> str: + attempts.append(ctx.retry_attempt) + if len(attempts) == 1: + first_attempt.set() + await fail.wait() + raise RuntimeError("Transient failure during the shutdown grace period") + await ctx.shutdown.wait() + return await ctx.exit_for_recovery() + + run = await flaky.start(task_id="ordinary-retry", input="saved") + shutdown = None + try: + await asyncio.wait_for(first_attempt.wait(), 2) + shutdown = asyncio.create_task(manager.shutdown()) + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + fail.set() + with pytest.raises(TaskDeferred): + await asyncio.wait_for(run.result(), 5) + await asyncio.wait_for(shutdown, 5) + assert attempts == [0, 1] + stored = await provider.get("ordinary-retry") + assert stored.status == "in_progress" + assert stored.payload["input"] == "saved" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + fail.set() + if shutdown is not None: + await asyncio.gather(shutdown, return_exceptions=True) + await manager.shutdown(wait_for_workers=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("preserve", [False, True], ids=["ordinary", "rollback"]) +async def test_shutdown_dispatch_of_accepted_work_depends_on_rollback(provider_factory, monkeypatch, preserve): + provider, _ = provider_factory + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=5) + _manager.set_task_manager(manager) + entered, release = asyncio.Event(), asyncio.Event() + observed, watchdogs = [], [] + original_remaining = manager._compute_remaining_for_watchdog + original_watchdog = manager._timeout_watchdog + + async def remaining(task_id, timeout_seconds, ctx): + entered.set() + await release.wait() + return await original_remaining(task_id, timeout_seconds, ctx) + + def watchdog(**kwargs): + # Record arming synchronously: a watchdog cancelled before its first step never runs its body. + watchdogs.append(kwargs["ctx"].task_id) + return original_watchdog(**kwargs) + + monkeypatch.setattr(manager, "_compute_remaining_for_watchdog", remaining) + monkeypatch.setattr(manager, "_timeout_watchdog", watchdog) + + @task(name="grace_accepted_deferral") + async def accepted(ctx: TaskContext[str]) -> str: + observed.append(ctx.entry_mode) + await ctx.shutdown.wait() + return await ctx.exit_for_recovery() + + run = await accepted.start(task_id="grace-accepted", input="saved") + shutdown = None + try: + await asyncio.wait_for(entered.wait(), 2) + shutdown = asyncio.create_task(manager.shutdown(wait_for_workers=preserve, preserve_recoverable_tasks=preserve)) + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + release.set() + with pytest.raises(TaskDeferred): + await asyncio.wait_for(run.result(), 5) + await asyncio.wait_for(shutdown, 5) + # Rollback retains accepted work without a handler; ordinary grace lets the handler defer it. + assert observed == ([] if preserve else ["fresh"]) + assert watchdogs == ([] if preserve else ["grace-accepted"]) + # A watchdog armed during ordinary grace is cancelled with its worker rather than orphaned. + await asyncio.wait_for(asyncio.gather(*list(manager._owned_workers), return_exceptions=True), 2) + assert not manager._has_live_workers + stored = await provider.get("grace-accepted") + assert stored.status == "in_progress" + assert stored.payload["input"] == "saved" + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + finally: + release.set() + if shutdown is not None: + await asyncio.gather(shutdown, return_exceptions=True) + await manager.shutdown(wait_for_workers=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reclaimed", [False, True], ids=["owned", "reclaimed-elsewhere"]) +async def test_ordinary_shutdown_releases_only_owned_staged_steering_lease(provider_factory, monkeypatch, reclaimed): + provider, _ = provider_factory + config = host().config + manager = TaskManager(config=config, provider=provider, shutdown_grace_seconds=5) + _manager.set_task_manager(manager) + running, finish_turn, recovered = asyncio.Event(), asyncio.Event(), asyncio.Event() + observed = [] + + @multi_turn_task(name="ordinary_staged_steering", steerable=True) + async def step(ctx: TaskContext[str]) -> str: + observed.append((ctx.input, ctx.entry_mode)) + if ctx.input == "first": + running.set() + await finish_turn.wait() + else: + recovered.set() + return ctx.input + + if reclaimed: + original_release = manager._release_staged_lease + + async def reclaim_then_release(task_id): + # Another instance takes the lease after staging; release must not expire its newer generation. + await provider.update( + task_id, + TaskPatchRequest( + lease_owner=manager._lease_owner, lease_instance_id="other-instance", lease_duration_seconds=60 + ), + ) + await original_release(task_id) + + monkeypatch.setattr(manager, "_release_staged_lease", reclaim_then_release) + + first = await step.start(task_id="staged-steering", input="first") + await asyncio.wait_for(running.wait(), 2) + staged_generation = (await provider.get("staged-steering")).lease.generation + queued = await step.start(task_id="staged-steering", input="next") + shutdown = asyncio.create_task(manager.shutdown()) + successor = None + try: + await asyncio.wait_for(manager._shutdown_event.wait(), 2) + finish_turn.set() + assert await asyncio.wait_for(first.result(), 2) == "first" + with pytest.raises(TaskDeferred): + await asyncio.wait_for(queued.result(), 2) + await asyncio.wait_for(shutdown, 5) + assert observed == [("first", "fresh")] + stored = await provider.get("staged-steering") + assert stored.status == "in_progress" + assert stored.payload["input"] == "next" + assert stored.payload["steering"]["pending_inputs"] == [] + if reclaimed: + assert stored.lease.instance_id == "other-instance" + assert stored.lease.generation == staged_generation + 1 + assert datetime.fromisoformat(stored.lease.expires_at) > datetime.now(timezone.utc) + return + assert (stored.lease.owner, stored.lease.instance_id) == (manager._lease_owner, manager._instance_id) + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + successor = TaskManager(config=config, provider=provider, shutdown_grace_seconds=0) + _manager.set_task_manager(successor) + await successor.startup() + await asyncio.wait_for(recovered.wait(), 2) + assert observed == [("first", "fresh"), ("next", "recovered")] + finally: + finish_turn.set() + await asyncio.gather(shutdown, return_exceptions=True) + if successor is not None: + await successor.shutdown(wait_for_workers=True) diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_task_activation.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_task_activation.py new file mode 100644 index 000000000000..145acbe2b1da --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_snapshot_task_activation.py @@ -0,0 +1,1882 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Session activation and application restore-handler task submissions.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import socket +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import httpx +import pytest +from starlette.testclient import TestClient + +from azure.ai.agentserver.core import AgentServerHost, AgentSessionContext +from azure.ai.agentserver.core._base import _read_memory_snapshot_enabled +from azure.ai.agentserver.core.tasks import ( + TaskContext, + TaskManagerNotInitialized, + multi_turn_task, + resilient_tasks_enabled, + set_resilient_tasks_enabled, + task, +) +from azure.ai.agentserver.core.tasks import _decorator, _manager +from azure.ai.agentserver.core.tasks._lease import derive_lease_owner +from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider +from azure.ai.agentserver.core.tasks._manager import TaskManager, get_task_manager +from azure.ai.agentserver.core.tasks._models import TaskCreateRequest, TaskPatchRequest + + +@pytest.fixture(autouse=True) +def isolate_runtime(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + previous_environment = dict(os.environ) + previous_manager = _manager._manager + previous_enabled = resilient_tasks_enabled() + previous_definitions = list(_decorator._REGISTERED_DESCRIPTORS) + _manager.set_task_manager(None) + set_resilient_tasks_enabled(True) + _decorator._REGISTERED_DESCRIPTORS.clear() + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "snapshot-agent") + monkeypatch.setenv("AGENTSERVER_SHUTDOWN_GRACE_SECONDS", "0") + for name in ( + "FOUNDRY_HOSTING_ENVIRONMENT", + "FOUNDRY_AGENT_SESSION_ID", + "FOUNDRY_AGENT_SESSION_GUID", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "true") + try: + yield + finally: + _manager.set_task_manager(previous_manager) + set_resilient_tasks_enabled(previous_enabled) + _decorator._REGISTERED_DESCRIPTORS[:] = previous_definitions + os.environ.clear() + os.environ.update(previous_environment) + + +@pytest.fixture +def provider_factory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[LocalFileTaskProvider, Mock]: + provider = LocalFileTaskProvider(base_dir=tmp_path) + factory = Mock(return_value=provider) + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(factory)) + return provider, factory + + +def payload(restore_id: str = "restore-1", session_id: str = "session-1") -> dict[str, Any]: + return { + "session_context": { + "session_id": session_id, + "restore_id": restore_id, + "session_env_overrides": { + "FOUNDRY_AGENT_SESSION_GUID": "a" * 32, + }, + } + } + + +def host() -> AgentServerHost: + return AgentServerHost(configure_observability=None, graceful_shutdown_timeout=0) + + +def client(app: AgentServerHost) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +@pytest.mark.parametrize( + ("value", "expected"), + [(None, False), ("true", True), ("TRUE", True), (" false ", False), ("false", False)], +) +def test_snapshot_flag_defaults_absence_to_false( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: bool +) -> None: + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + assert _read_memory_snapshot_enabled() is expected + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("value", [None, "false", "true"]) +def test_sequential_lifespans_restart_enabled_tasks( + monkeypatch: pytest.MonkeyPatch, + provider_factory: tuple[LocalFileTaskProvider, Mock], + enabled: bool, + value: str | None, +) -> None: + set_resilient_tasks_enabled(enabled) + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + _, factory = provider_factory + app = host() + managers: list[TaskManager] = [] + + for cycle in range(2): + with TestClient(app) as http: + assert http.get("/readiness").status_code == 200 + if value == "true": + assert app._task_manager is None + response = http.post("/_agent/after-restore", json=payload(restore_id=f"cycle-{cycle}")) + assert response.status_code == 200, response.text + if enabled: + managers.append(get_task_manager()) + assert managers[-1] is app._task_manager + else: + factory.assert_not_called() + assert app._task_manager is None + assert app._task_manager is None + + if enabled: + assert managers[0] is not managers[1] + assert factory.call_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", ["", " ", "0", "1", "yes", "invalid"]) +async def test_invalid_present_flag_fails_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + app = host() + with pytest.raises(ValueError, match="must be 'true' or 'false'"): + async with app.router.lifespan_context(app): + pytest.fail("Invalid configuration was accepted") + + +@pytest.mark.asyncio +async def test_capture_and_snapshot_startup_do_not_construct_tasks( + provider_factory: tuple[LocalFileTaskProvider, Mock], +) -> None: + _, factory = provider_factory + app = host() + async with app.router.lifespan_context(app), client(app) as http: + assert (await http.get("/readiness")).status_code == 200 + assert (await http.post("/_agent/before-snapshot", json={})).status_code == 200 + factory.assert_not_called() + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [None, "false"]) +async def test_false_or_absent_keeps_cold_activation( + monkeypatch: pytest.MonkeyPatch, + provider_factory: tuple[LocalFileTaskProvider, Mock], + value: str | None, +) -> None: + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "cold-session") + _, factory = provider_factory + app = host() + async with app.router.lifespan_context(app): + manager = get_task_manager() + assert manager is app._task_manager + assert manager._lease_owner == derive_lease_owner("snapshot-agent", "cold-session") + assert manager._periodic_recovery_task is not None + assert not manager._periodic_recovery_task.done() + assert all(phase.completed for phase in manager._startup_phases.values()) + await manager.startup() + factory.assert_called_once() + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("value", [None, "true", "false"]) +async def test_startup_and_restore_honor_resilience_and_snapshot_flags( + monkeypatch: pytest.MonkeyPatch, + provider_factory: tuple[LocalFileTaskProvider, Mock], + enabled: bool, + value: str | None, +) -> None: + set_resilient_tasks_enabled(enabled) + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + _, factory = provider_factory + app = host() + async with app.router.lifespan_context(app), client(app) as http: + if enabled and value != "true": + assert get_task_manager() is app._task_manager + factory.assert_called_once() + else: + factory.assert_not_called() + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + if enabled: + manager = get_task_manager() + assert manager is app._task_manager + assert manager._config.session_id == "session-1" + assert manager._periodic_recovery_task is not None + assert not manager._periodic_recovery_task.done() + factory.assert_called_once() + else: + factory.assert_not_called() + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + assert os.environ.get("FOUNDRY_MEMORY_SNAPSHOT_ENABLED") == value + assert app._task_manager is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [None, "false"]) +async def test_cold_runtime_rejects_restore_for_another_session( + monkeypatch: pytest.MonkeyPatch, + provider_factory: tuple[LocalFileTaskProvider, Mock], + value: str | None, +) -> None: + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "cold-session") + _, factory = provider_factory + app = host() + async with app.router.lifespan_context(app), client(app) as http: + manager = get_task_manager() + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 409 + assert get_task_manager() is manager + assert manager._config.session_id == "cold-session" + factory.assert_called_once() + + +@pytest.mark.asyncio +async def test_restore_prepares_manager_before_handler_and_recovers_after_dependencies( + provider_factory: tuple[LocalFileTaskProvider, Mock], +) -> None: + _, factory = provider_factory + + @task(name="registered_before_capture") + async def declaration(ctx: TaskContext[dict[str, str]]) -> str: + return ctx.input["value"] + + app = host() + callback_seen = False + + @app.after_restore_handler + async def dependencies(context: AgentSessionContext) -> None: + nonlocal callback_seen + assert context.session_id == "session-1" + assert app.config.session_guid == "a" * 32 + manager = get_task_manager() + assert app._task_manager is manager + assert manager._resume_callbacks[declaration.name] is declaration._fn + assert manager._startup_phases["register"].completed + assert not manager._startup_phases["recovery"].completed + assert manager._periodic_recovery_task is None + callback_seen = True + + async with app.router.lifespan_context(app), client(app) as http: + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + manager = get_task_manager() + assert callback_seen + assert manager._config.session_id == "session-1" + assert manager._lease_owner == derive_lease_owner("snapshot-agent", "session-1") + assert manager._resume_callbacks[declaration.name] is declaration._fn + assert len(_decorator._REGISTERED_DESCRIPTORS) == 1 + assert all( + phase.completed and phase.completed_restore_id == "restore-1" for phase in manager._startup_phases.values() + ) + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + factory.assert_called_once() + assert app._task_manager is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["one_shot", "multi_turn", "steerable"]) +@pytest.mark.parametrize("inside_handler", [False, True]) +@pytest.mark.parametrize("submission", ["start", "run"]) +async def test_restore_handler_submits_tasks_before_automatic_recovery( + provider_factory: tuple[LocalFileTaskProvider, Mock], + inside_handler: bool, + submission: str, + kind: str, +) -> None: + provider, factory = provider_factory + started, finish, recovered = asyncio.Event(), asyncio.Event(), asyncio.Event() + dependencies_ready = False + callback_finished = False + calls: list[str] = [] + app = host() + + def declare() -> Any: + decorator = ( + task(name="handler_startup") + if kind == "one_shot" + else multi_turn_task(name="handler_startup", steerable=kind == "steerable") + ) + + @decorator + async def startup(ctx: TaskContext[dict[str, str]]) -> str: + assert dependencies_ready + assert ctx.input == {"session": "session-1"} + calls.append(ctx.task_id) + started.set() + if submission == "start": + await finish.wait() + return "initialized" + + return startup + + startup = None if inside_handler else declare() + + @task(name="recover_after_dependencies") + async def previous(ctx: TaskContext[dict[str, str]]) -> str: + assert dependencies_ready and callback_finished + assert ctx.input == {"value": "saved-input"} + recovered.set() + return "recovered" + + await seed_expired_task(provider, previous.name) + callbacks = 0 + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + nonlocal dependencies_ready, callback_finished, callbacks + callbacks += 1 + manager = get_task_manager() + assert manager._periodic_recovery_task is None + assert not manager._startup_phases["recovery"].completed + dependencies_ready = True + declaration = declare() if inside_handler else startup + if submission == "start": + run = await declaration.start(task_id="handler-startup", input={"session": context.session_id}) + assert run.task_id == "handler-startup" + await asyncio.wait_for(started.wait(), 2) + else: + assert ( + await declaration.run(task_id="handler-startup", input={"session": context.session_id}) + ) == "initialized" + assert not recovered.is_set() + callback_finished = True + + async with app.router.lifespan_context(app), client(app) as http: + try: + responses = await asyncio.gather( + http.post("/_agent/after-restore", json=payload()), + http.post("/_agent/after-restore", json=payload()), + ) + assert [response.status_code for response in responses] == [200, 200] + await asyncio.wait_for(recovered.wait(), 2) + assert calls == ["handler-startup"] + assert callbacks == 1 + manager = get_task_manager() + assert all(phase.completed for phase in manager._startup_phases.values()) + assert manager._periodic_recovery_task is not None + assert not manager._periodic_recovery_task.done() + factory.assert_called_once() + finally: + finish.set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("matching_binding", [False, True]) +async def test_restore_handler_task_survives_failed_hook_without_duplicate_execution( + provider_factory: tuple[LocalFileTaskProvider, Mock], + monkeypatch: pytest.MonkeyPatch, + failure: str, + matching_binding: bool, +) -> None: + if matching_binding: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + provider, factory = provider_factory + running, finish, callback_failed = asyncio.Event(), asyncio.Event(), asyncio.Event() + calls: list[tuple[str, str, Any]] = [] + managers: list[TaskManager] = [] + app = host() + + @task(name="retry_handler_task") + async def startup(ctx: TaskContext[dict[str, str]]) -> str: + calls.append((ctx.task_id, ctx.entry_mode, ctx.input)) + running.set() + await finish.wait() + return "finished" + + callbacks = 0 + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + nonlocal callbacks + callbacks += 1 + managers.append(get_task_manager()) + run = await startup.get_active_run("retry-startup") + if run is None: + await startup.start(task_id="retry-startup", input={"session": context.session_id}) + await asyncio.wait_for(running.wait(), 2) + if callbacks == 1: + callback_failed.set() + if failure == "exception": + raise RuntimeError("Failure after startup submission") + await asyncio.Event().wait() + + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(callback_failed.wait(), 2) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + stored = await provider.get("retry-startup") + assert stored is not None and stored.status == "in_progress" + assert stored.payload is not None and stored.payload["input"] == {"session": "session-1"} + if not matching_binding: + assert app._task_manager is None + assert not managers[0]._has_live_workers + running.clear() + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + await asyncio.wait_for(running.wait(), 2) + assert calls == [ + ("retry-startup", "fresh", {"session": "session-1"}), + *([] if matching_binding else [("retry-startup", "recovered", {"session": "session-1"})]), + ] + assert (managers[0] is managers[1]) == matching_binding + assert factory.call_count == (1 if matching_binding else 2) + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + assert callbacks == 2 + finally: + finish.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +@pytest.mark.parametrize("provider_change", ["endpoint", "backend", "state_root"]) +async def test_matching_session_rollback_stops_incompatible_provider_before_retry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + failure: str, + provider_change: str, +) -> None: + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example.invalid/project") + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local" if provider_change == "state_root" else "hosted") + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path / "captured")) + running, finish, failing = asyncio.Event(), asyncio.Event(), asyncio.Event() + managers: list[TaskManager] = [] + stores: list[LocalFileTaskProvider] = [] + final_bindings: list[tuple[str, str | None]] = [] + calls = 0 + + def create_provider(config: Any) -> Any: + local = os.environ["AGENTSERVER_TASKS_BACKEND"] == "local" + location = ( + Path(os.environ["AGENTSERVER_STATE_ROOT"]) / "tasks" if local else tmp_path / f"provider-{len(stores)}" + ) + store = LocalFileTaskProvider(location) + stores.append(store) + if local: + return store + provider = HostedTaskProvider(project_endpoint=config.project_endpoint, credential=Mock()) + for method in ("create", "get", "update", "delete", "list"): + monkeypatch.setattr(provider, method, getattr(store, method)) + return provider + + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(create_provider)) + app = host() + + @task(name="provider_rollback_startup") + async def startup(ctx: TaskContext[dict[str, str]]) -> str: + running.set() + try: + await finish.wait() + return ctx.input["session"] + finally: + final_bindings.append(get_task_manager()._provider_configuration()) + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + nonlocal calls + calls += 1 + managers.append(get_task_manager()) + if calls == 1: + await startup.start(task_id="provider-startup", input={"session": context.session_id}) + await asyncio.wait_for(running.wait(), 2) + failing.set() + if failure == "exception": + raise RuntimeError("Failure with matching identity and changed provider") + await asyncio.Event().wait() + assert app.config.project_endpoint == "https://captured.example.invalid/project" + + first_payload = payload() + key, value = { + "endpoint": ("FOUNDRY_PROJECT_ENDPOINT", "https://failed.example.invalid/project"), + "backend": ("AGENTSERVER_TASKS_BACKEND", "local"), + "state_root": ("AGENTSERVER_STATE_ROOT", str(tmp_path / "overridden")), + }[provider_change] + first_payload["session_context"]["session_env_overrides"][key] = value + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=first_payload)) + try: + await asyncio.wait_for(failing.wait(), 2) + original_binding = managers[0]._provider_binding + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert calls == 2 + assert managers[0] is not managers[1] + assert managers[0]._shutdown_event.is_set() + assert not managers[0]._has_live_workers + assert final_bindings == [original_binding] + assert get_task_manager()._provider_configuration_matches() + if provider_change != "state_root": + assert isinstance(managers[1].provider, HostedTaskProvider) + assert managers[1].provider._base_url == "https://captured.example.invalid/project/tasks" + stored = await stores[0].get("provider-startup") + assert stored is not None and stored.status == "in_progress" + assert stored.payload is not None and stored.payload["input"] == {"session": "session-1"} + assert stored.lease is not None + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + assert await stores[1].get("provider-startup") is None + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + assert calls == 2 + finally: + finish.set() + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_restore_handler_task_submission_honors_disabled_resilience( + provider_factory: tuple[LocalFileTaskProvider, Mock], +) -> None: + set_resilient_tasks_enabled(False) + _, factory = provider_factory + app = host() + + @task(name="disabled_handler_task") + async def startup(ctx: TaskContext[str]) -> str: + pytest.fail("Disabled task must not execute") + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + with pytest.raises(TaskManagerNotInitialized): + await startup.start(input=context.session_id) + + async with app.router.lifespan_context(app), client(app) as http: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + factory.assert_not_called() + + +@pytest.mark.asyncio +async def test_inactive_prepared_provider_is_rebuilt_for_changed_restore_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + entered = asyncio.Event() + providers: list[HostedTaskProvider] = [] + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example.invalid/project") + + def create_provider(config: Any) -> HostedTaskProvider: + provider = HostedTaskProvider(project_endpoint=config.project_endpoint, credential=Mock()) + providers.append(provider) + + async def listing(**_kwargs: Any) -> list[Any]: + if len(providers) == 1: + entered.set() + await asyncio.Event().wait() + return [] + + monkeypatch.setattr(provider, "list", listing) + return provider + + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(create_provider)) + app = host() + async with app.router.lifespan_context(app), client(app) as http: + first_payload = payload() + first_payload["session_context"]["session_env_overrides"][ + "FOUNDRY_PROJECT_ENDPOINT" + ] = "https://failed.example.invalid/project" + request = asyncio.create_task(http.post("/_agent/after-restore", json=first_payload)) + try: + await asyncio.wait_for(entered.wait(), 2) + stale = get_task_manager() + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + assert not stale._has_live_workers + assert stale._shutdown_event.is_set() + assert providers[0]._base_url == "https://failed.example.invalid/project/tasks" + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert get_task_manager() is not stale + assert stale._shutdown_event.is_set() + assert len(providers) == 2 + assert get_task_manager().provider is providers[1] + assert providers[1]._base_url == "https://captured.example.invalid/project/tasks" + finally: + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("change", ["session", "provider", "compatible"]) +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +async def test_failed_restore_unpublishes_incompatible_prepared_runtime( + provider_factory, monkeypatch: pytest.MonkeyPatch, change: str, failure: str +) -> None: + provider, factory = provider_factory + captured_endpoint = "https://captured.example.invalid/project" + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", captured_endpoint) + entered = asyncio.Event() + managers = [] + app = host() + + @task(name="prepared_rollback_submission") + async def work(ctx: TaskContext[str]) -> str: + return ctx.input + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + managers.append(get_task_manager()) + if len(managers) != 1: + return + assert not managers[0]._has_live_workers + entered.set() + if failure == "exception": + raise RuntimeError("Fail before submitting work") + await asyncio.Event().wait() + + first = payload(session_id="session-2" if change == "session" else "session-1") + if change == "session": + first["session_context"]["session_env_overrides"]["FOUNDRY_AGENT_SESSION_GUID"] = "b" * 32 + elif change == "provider": + first["session_context"]["session_env_overrides"][ + "FOUNDRY_PROJECT_ENDPOINT" + ] = "https://failed.example.invalid/project" + async with app.router.lifespan_context(app), client(app) as http: + request = asyncio.create_task(http.post("/_agent/after-restore", json=first)) + try: + await asyncio.wait_for(entered.wait(), 10) + if failure == "cancel": + request.cancel() + with pytest.raises(asyncio.CancelledError): + await request + else: + assert (await request).status_code == 500 + assert app.config.session_id == "session-1" + assert app.config.project_endpoint == captured_endpoint + if change == "compatible": + assert get_task_manager() is app._task_manager is managers[0] + assert not managers[0]._shutdown_event.is_set() + else: + assert app._task_manager is None + assert managers[0]._shutdown_event.is_set() + with pytest.raises(TaskManagerNotInitialized): + await work.start(task_id="after-failure", input="must-not-be-persisted") + assert await provider.get("after-failure") is None + response = await http.post( + "/_agent/after-restore", + json=payload( + restore_id="restore-2", + session_id=first["session_context"]["session_id"], + ), + ) + assert response.status_code == 200, response.text + if change == "compatible": + assert managers[1] is managers[0] + assert factory.call_count == 1 + else: + assert managers[1] is not managers[0] + assert factory.call_count == 2 + finally: + if not request.done(): + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_completed_restore_recreates_disposed_runtime_without_replaying_handler( + provider_factory: tuple[LocalFileTaskProvider, Mock], +) -> None: + _, factory = provider_factory + app = host() + calls = 0 + managers: list[TaskManager] = [] + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + nonlocal calls + assert context.restore_id == "restore-1" + calls += 1 + + for _ in range(2): + async with app.router.lifespan_context(app), client(app) as http: + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + manager = get_task_manager() + managers.append(manager) + assert manager._startup_phases["ready"].completed + assert manager._periodic_recovery_task is not None + assert not manager._periodic_recovery_task.done() + assert calls == 1 + assert managers[0] is not managers[1] + assert factory.call_count == 2 + + +@pytest.mark.asyncio +async def test_duplicate_inflight_restore_has_one_runtime_and_scanner( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + _, factory = provider_factory + entered, release = asyncio.Event(), asyncio.Event() + original = TaskManager._recover_stale_tasks + scans = 0 + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + entered.set() + await release.wait() + await original(manager) + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + app = host() + async with app.router.lifespan_context(app), client(app) as http: + first = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + await asyncio.wait_for(entered.wait(), 2) + second = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + await asyncio.sleep(0) + assert not second.done() + release.set() + responses = await asyncio.gather(first, second) + assert [response.status_code for response in responses] == [200, 200] + scanner = get_task_manager()._periodic_recovery_task + assert scanner is not None and not scanner.done() + assert scans == 1 + factory.assert_called_once() + assert scanner.done() + + +@pytest.mark.asyncio +async def test_partial_recovery_failure_reuses_valid_inactive_phases( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + _, factory = provider_factory + original = TaskManager._recover_stale_tasks + scans = 0 + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + if scans == 1: + raise RuntimeError("Interrupted essential initialization") + await original(manager) + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + app = host() + async with app.router.lifespan_context(app), client(app) as http: + first = await http.post("/_agent/after-restore", json=payload()) + assert first.status_code == 500 + manager = app._task_manager + assert manager is not None + assert manager._startup_phases["register"].completed + assert not manager._startup_phases["recovery"].completed + assert not manager._has_live_workers + assert app.config.session_id == "session-1" + wrong_session = await http.post("/_agent/after-restore", json=payload(session_id="another-session")) + assert wrong_session.status_code == 409 + second = await http.post("/_agent/after-restore", json=payload()) + assert second.status_code == 200, second.text + assert get_task_manager() is manager + assert scans == 2 + factory.assert_called_once() + + +@pytest.mark.asyncio +async def test_completed_recovery_is_not_repeated_after_scanner_creation_failure( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + _, factory = provider_factory + original_recovery = TaskManager._recover_stale_tasks + loop = asyncio.get_running_loop() + original_create = loop.create_task + scans = 0 + fail_scanner = True + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + await original_recovery(manager) + + def create(coroutine: Any, *args: Any, **kwargs: Any) -> asyncio.Task[Any]: + nonlocal fail_scanner + code = getattr(coroutine, "cr_code", None) + if fail_scanner and code is not None and code.co_name == "_periodic_recovery_loop": + fail_scanner = False + coroutine.close() + raise RuntimeError("Scanner allocation failed") + return original_create(coroutine, *args, **kwargs) + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + monkeypatch.setattr(loop, "create_task", create) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + app = host() + async with app.router.lifespan_context(app), client(app) as http: + first = await http.post("/_agent/after-restore", json=payload()) + assert first.status_code == 500 + manager = get_task_manager() + assert manager._startup_phases["recovery"].completed + assert not manager._startup_phases["periodic"].completed + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 200 + assert get_task_manager() is manager + assert scans == 1 + factory.assert_called_once() + + +@pytest.mark.asyncio +async def test_list_failure_keeps_best_effort_startup_and_periodic_retry( + provider_factory: tuple[LocalFileTaskProvider, Mock], + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.WARNING, logger="azure.ai.agentserver") + provider, _ = provider_factory + calls = 0 + retried = asyncio.Event() + + async def listing(**kwargs: Any) -> list[Any]: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("Temporary LIST failure") + retried.set() + return [] + + monkeypatch.setattr(provider, "list", listing) + monkeypatch.setattr(_manager, "_PERIODIC_RECOVERY_INTERVAL_SECONDS", 0.01) + app = host() + async with app.router.lifespan_context(app), client(app) as http: + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert get_task_manager()._startup_phases["recovery"].completed + await asyncio.wait_for(retried.wait(), 2) + assert "Temporary LIST failure" in caplog.text + + +async def seed_expired_task(provider: LocalFileTaskProvider, name: str, *, steering: bool = False) -> None: + owner = derive_lease_owner("snapshot-agent", "session-1") + task_payload: dict[str, Any] = {"input": {"value": "saved-input"}, "schema_version": "1"} + if steering: + task_payload["steering"] = { + "cancel_requested": True, + "active_input": {"value": "saved-input"}, + "pending_inputs": [{"value": "queued-input"}], + } + await provider.create( + TaskCreateRequest( + id="existing-task", + agent_name="snapshot-agent", + session_id="session-1", + status="in_progress", + title="Existing LRA", + payload=task_payload, + source={"type": "agentserver.task", "name": name}, + tags={"task_name": name}, + lease_owner=owner, + lease_instance_id="old-executor", + lease_duration_seconds=60, + ) + ) + await provider.update( + "existing-task", + TaskPatchRequest( + lease_owner=owner, + lease_instance_id="old-executor", + lease_duration_seconds=0, + ), + ) + + +@pytest.mark.asyncio +async def test_recovered_task_runs_before_hook_success( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + provider, _ = provider_factory + running, finish, release_hook = asyncio.Event(), asyncio.Event(), asyncio.Event() + heartbeat = asyncio.Event() + observed: list[tuple[Any, str, bool]] = [] + app = host() + + @task(name="existing_lra") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + observed.append((ctx.input, ctx.entry_mode, app._lifecycle.restored_session_context is not None)) + running.set() + await finish.wait() + return "done" + + await seed_expired_task(provider, existing.name) + monkeypatch.setattr(_manager, "_DEFAULT_LEASE_SECONDS", 10) + original_update = provider.update + + async def update(task_id: str, patch: TaskPatchRequest) -> Any: + result = await original_update(task_id, patch) + if running.is_set() and patch.lease_duration_seconds == 10: + heartbeat.set() + return result + + monkeypatch.setattr(provider, "update", update) + original = TaskManager._recover_stale_tasks + + async def recovery(manager: TaskManager) -> None: + await original(manager) + await running.wait() + await release_hook.wait() + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + async with app.router.lifespan_context(app), client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(running.wait(), 2) + assert not restore.done() + assert observed == [({"value": "saved-input"}, "recovered", False)] + assert app._lifecycle.lock.locked() + assert app._lifecycle.restored_session_context is None + await asyncio.wait_for(heartbeat.wait(), 7) + assert not restore.done() + release_hook.set() + assert (await restore).status_code == 200 + finally: + release_hook.set() + finish.set() + if not restore.done(): + restore.cancel() + with contextlib.suppress(asyncio.CancelledError): + await restore + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["cancel", "exception"]) +@pytest.mark.parametrize("kind", ["one_shot", "multi_turn", "steerable"]) +@pytest.mark.parametrize("cooperative", [False, True]) +async def test_cancelled_recovery_stops_workers_before_binding_rollback_and_retries( + provider_factory: tuple[LocalFileTaskProvider, Mock], + monkeypatch: pytest.MonkeyPatch, + failure: str, + kind: str, + cooperative: bool, +) -> None: + provider, factory = provider_factory + running, finish = asyncio.Event(), asyncio.Event() + final_bindings: list[str] = [] + resumed_inputs: list[tuple[str, Any, str]] = [] + app = host() + + decorator = ( + task(name="cancelled_lra") + if kind == "one_shot" + else multi_turn_task(name="cancelled_lra", steerable=kind == "steerable") + ) + + @decorator + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + resumed_inputs.append((ctx.task_id, ctx.input, ctx.entry_mode)) + running.set() + try: + if cooperative: + await ctx.shutdown.wait() + raise asyncio.CancelledError + await finish.wait() + return ctx.input["value"] + finally: + final_bindings.append(app.config.session_id) + + await seed_expired_task(provider, existing.name, steering=kind == "steerable") + original = TaskManager._recover_stale_tasks + scans = 0 + managers: list[TaskManager] = [] + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + managers.append(manager) + await original(manager) + if scans == 1: + await running.wait() + if failure == "exception": + raise RuntimeError("Failure after recovered work started") + await asyncio.Event().wait() + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + async with app.router.lifespan_context(app), client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(running.wait(), 2) + first = managers[0] + first_instance = first._instance_id + if failure == "cancel": + restore.cancel() + done, _ = await asyncio.wait({restore}, timeout=2) + assert restore in done, "Rollback did not finish after stopping the original execution" + if failure == "cancel": + with pytest.raises(asyncio.CancelledError): + await restore + else: + assert (await restore).status_code == 500 + assert final_bindings == ["session-1"] + assert not first._active_tasks + assert first._shutdown_event.is_set() + assert app.config.session_id == "" + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + stored = await provider.get("existing-task") + assert stored is not None + assert stored.status == "in_progress" + assert stored.payload["input"] == {"value": "saved-input"} + assert stored.lease is not None + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + if kind == "steerable": + assert stored.payload["steering"]["pending_inputs"] == [{"value": "queued-input"}] + running.clear() + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + second = get_task_manager() + assert second is not first + assert second._instance_id != first_instance + assert second._lease_owner == first._lease_owner + assert factory.call_count == 2 + await asyncio.wait_for(running.wait(), 2) + assert resumed_inputs == [ + ("existing-task", {"value": "saved-input"}, "recovered"), + ("existing-task", {"value": "saved-input"}, "recovered"), + ] + finally: + finish.set() + if not restore.done(): + restore.cancel() + with contextlib.suppress(asyncio.CancelledError): + await restore + + +@pytest.mark.asyncio +async def test_retry_with_unchanged_binding_keeps_live_worker_identity( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + provider, _ = provider_factory + running, finish = asyncio.Event(), asyncio.Event() + app = host() + + @task(name="same_binding_recovery") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + running.set() + await finish.wait() + return ctx.input["value"] + + await seed_expired_task(provider, existing.name) + original = TaskManager._recover_stale_tasks + scans = 0 + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + await original(manager) + if scans == 1: + await running.wait() + raise RuntimeError("Recovery failed after dispatch under an unchanged binding") + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 500 + manager = get_task_manager() + instance = manager._instance_id + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert get_task_manager() is manager + assert manager._instance_id == instance + stored = await provider.get("existing-task") + assert stored is not None and stored.lease is not None + assert stored.lease.instance_id == instance + finally: + finish.set() + + +@pytest.mark.asyncio +async def test_rollback_joins_renewal_after_handler_observes_shutdown( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + provider, _ = provider_factory + running, renewal_started, renewal_cancelled, release = (asyncio.Event() for _ in range(4)) + final_bindings: list[str] = [] + app = host() + + @task(name="cooperative_rollback") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + running.set() + await ctx.shutdown.wait() + raise asyncio.CancelledError + + async def renewal(*_args: Any, **_kwargs: Any) -> None: + renewal_started.set() + try: + await release.wait() + except asyncio.CancelledError: + renewal_cancelled.set() + await release.wait() + finally: + final_bindings.append(app.config.session_id) + + await seed_expired_task(provider, existing.name) + original = TaskManager._recover_stale_tasks + + async def recovery(manager: TaskManager) -> None: + await original(manager) + await running.wait() + await renewal_started.wait() + raise RuntimeError("Recovery failed with renewal still in flight") + + monkeypatch.setattr(_manager, "lease_renewal_loop", renewal) + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + async with app.router.lifespan_context(app), client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(renewal_started.wait(), 2) + done, _ = await asyncio.wait({restore}, timeout=0.1) + assert not done, "Configuration rolled back while a retained renewal worker was still running" + assert renewal_cancelled.is_set() + assert app.config.session_id == "session-1" + finally: + release.set() + response = await restore + assert response.status_code == 500 + assert final_bindings == ["session-1"] + assert app.config.session_id == "" + stored = await provider.get("existing-task") + assert stored is not None and stored.lease is not None + assert datetime.fromisoformat(stored.lease.expires_at) <= datetime.now(timezone.utc) + + +@pytest.mark.asyncio +async def test_rollback_joins_renewal_after_completed_handler_leaves_active_entries( + provider_factory: tuple[LocalFileTaskProvider, Mock], monkeypatch: pytest.MonkeyPatch +) -> None: + provider, _ = provider_factory + running, complete, renewal_started, renewal_cancelled, release = (asyncio.Event() for _ in range(5)) + final_bindings: list[str] = [] + managers: list[TaskManager] = [] + app = host() + + @task(name="completed_handler_rollback") + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + running.set() + await complete.wait() + return ctx.input["value"] + + async def renewal(*_args: Any, **_kwargs: Any) -> None: + renewal_started.set() + try: + await release.wait() + except asyncio.CancelledError: + renewal_cancelled.set() + await release.wait() + finally: + final_bindings.append(app.config.session_id) + + await seed_expired_task(provider, existing.name) + original = TaskManager._recover_stale_tasks + + async def recovery(manager: TaskManager) -> None: + managers.append(manager) + await original(manager) + await running.wait() + await renewal_started.wait() + execution = manager._active_tasks["existing-task"].execution_task + complete.set() + await execution + assert not manager._active_tasks + raise RuntimeError("Recovery failed after a completed handler dropped its active entry") + + monkeypatch.setattr(_manager, "lease_renewal_loop", renewal) + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + async with app.router.lifespan_context(app), client(app) as http: + restore = asyncio.create_task(http.post("/_agent/after-restore", json=payload())) + try: + await asyncio.wait_for(renewal_started.wait(), 2) + done, _ = await asyncio.wait({restore}, timeout=0.1) + assert not done, "Rollback missed a renewal whose handler had already completed" + assert renewal_cancelled.is_set() + assert app.config.session_id == "session-1" + finally: + release.set() + response = await restore + assert response.status_code == 500 + assert final_bindings == ["session-1"] + assert app.config.session_id == "" + assert not managers[0]._has_live_workers + assert not managers[0]._owned_workers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("interrupt_drain", [False, True]) +async def test_rollback_defers_queued_input_after_current_turn_returns_normally( + provider_factory: tuple[LocalFileTaskProvider, Mock], + monkeypatch: pytest.MonkeyPatch, + interrupt_drain: bool, +) -> None: + monkeypatch.setenv("AGENTSERVER_SHUTDOWN_GRACE_SECONDS", "0.2") + provider, _ = provider_factory + running, queued, finish = (asyncio.Event() for _ in range(3)) + observed: list[tuple[Any, str]] = [] + app = host() + + @multi_turn_task(name="normal_return_rollback", steerable=True) + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + observed.append((ctx.input, ctx.entry_mode)) + if ctx.input["value"] == "saved-input": + running.set() + await ctx.shutdown.wait() + return "current turn finished" + queued.set() + await finish.wait() + return ctx.input["value"] + + await seed_expired_task(provider, existing.name, steering=True) + original = TaskManager._recover_stale_tasks + original_get = TaskManager._provider_get_tracked + drain_cancelled = asyncio.Event() + blocked = False + scans = 0 + + async def read_for_drain(manager: TaskManager, task_id: str) -> Any: + nonlocal blocked + stored = await original_get(manager, task_id) + if interrupt_drain and not blocked and stored is not None and stored.status == "suspended": + blocked = True + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + drain_cancelled.set() + raise + return stored + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + await original(manager) + if scans == 1: + await running.wait() + raise RuntimeError("Recovery failed before the handler returned during shutdown") + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + monkeypatch.setattr(TaskManager, "_provider_get_tracked", read_for_drain) + async with app.router.lifespan_context(app), client(app) as http: + try: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 500 + assert not queued.is_set() + assert drain_cancelled.is_set() == interrupt_drain + stored = await provider.get("existing-task") + assert stored is not None + assert stored.status == "in_progress" + assert stored.payload["input"] == {"value": "queued-input"} + assert stored.payload["steering"]["pending_inputs"] == [] + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + await asyncio.wait_for(queued.wait(), 2) + assert observed == [ + ({"value": "saved-input"}, "recovered"), + ({"value": "queued-input"}, "recovered"), + ] + finally: + finish.set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("repeat_override", [False, True], ids=["omitted-override", "repeated-override"]) +@pytest.mark.parametrize("new_restore", [False, True], ids=["same-restore", "new-restore"]) +@pytest.mark.parametrize( + ("failure", "failures", "retry_mode"), + [ + pytest.param("read", 1, "restore", id="read-once"), + pytest.param("read", 2, "restore", id="read-twice"), + pytest.param("claim", 1, "restore", id="claim-once"), + pytest.param("claim", 2, "restore", id="claim-twice"), + pytest.param("committed_claim", 1, "restore", id="committed-claim"), + pytest.param("read", 1, "cancel", id="cancelled-cleanup"), + pytest.param("read", 1, "shutdown", id="shutdown-cleanup"), + ], +) +async def test_restore_retries_pending_cleanup_without_losing_queued_inputs( + provider_factory: tuple[LocalFileTaskProvider, Mock], + monkeypatch: pytest.MonkeyPatch, + failure: str, + failures: int, + retry_mode: str, + new_restore: bool, + repeat_override: bool, +) -> None: + monkeypatch.setenv("AGENTSERVER_SHUTDOWN_GRACE_SECONDS", "0.2") + captured_endpoint = "https://captured.example.invalid/project" + failed_endpoint = "https://failed.example.invalid/project" + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", captured_endpoint) + provider, factory = provider_factory + running, queued, finish, drain_cancelled = (asyncio.Event() for _ in range(4)) + observed: list[Any] = [] + callbacks: list[str] = [] + in_reconciliation = False + block_reconciliation = False + cleanup_entered, release_cleanup = asyncio.Event(), asyncio.Event() + cleanup_request: asyncio.Task[httpx.Response] | None = None + remaining_failures = failures + scans = 0 + app = host() + + @app.after_restore_handler + async def dependencies(context: AgentSessionContext) -> None: + callbacks.append(context.restore_id) + endpoint = ( + failed_endpoint if len(callbacks) == 1 or (len(callbacks) == 2 and repeat_override) else captured_endpoint + ) + assert os.environ["FOUNDRY_PROJECT_ENDPOINT"] == app.config.project_endpoint == endpoint + if len(callbacks) < 3: + assert app._task_manager is get_task_manager() + assert not get_task_manager()._startup_phases["ready"].completed + + @multi_turn_task(name="retry_cleanup", steerable=True) + async def existing(ctx: TaskContext[dict[str, str]]) -> str: + observed.append(ctx.input) + if ctx.input["value"] == "saved-input": + running.set() + await ctx.shutdown.wait() + return "current turn finished" + queued.set() + await finish.wait() + return ctx.input["value"] + + await seed_expired_task(provider, existing.name, steering=True) + stored = await provider.get("existing-task") + assert stored is not None and stored.payload is not None + steering = dict(stored.payload["steering"]) + steering["pending_inputs"].append({"value": "later-input"}) + await provider.update("existing-task", TaskPatchRequest(payload={"steering": steering})) + original_read = TaskManager._provider_get_tracked + original_update = provider.update + original_reconcile = TaskManager._reconcile_steering_for_rollback + original_recovery = TaskManager._recover_stale_tasks + + async def read(manager: TaskManager, task_id: str) -> Any: + nonlocal remaining_failures + if in_reconciliation and failure == "read" and remaining_failures: + remaining_failures -= 1 + raise RuntimeError("Temporary reconciliation read failure") + result = await original_read(manager, task_id) + if not in_reconciliation and result is not None and result.status == "suspended": + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + drain_cancelled.set() + raise + return result + + async def update(task_id: str, patch: TaskPatchRequest) -> Any: + nonlocal remaining_failures + if in_reconciliation and patch.status == "in_progress" and failure != "read" and remaining_failures: + remaining_failures -= 1 + if failure == "committed_claim": + await original_update(task_id, patch) + raise RuntimeError("Temporary reconciliation claim failure") + return await original_update(task_id, patch) + + async def reconcile(manager: TaskManager, active_tasks: list[Any]) -> None: + nonlocal in_reconciliation + assert manager._materialization_restore_id == "restore-1" + assert manager._config.session_guid == "a" * 32 + in_reconciliation = True + try: + if block_reconciliation: + cleanup_entered.set() + await release_cleanup.wait() + await original_reconcile(manager, active_tasks) + finally: + in_reconciliation = False + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + await original_recovery(manager) + if scans == 1: + await asyncio.wait_for(running.wait(), 2) + raise RuntimeError("Trigger rollback with pending steering input") + + monkeypatch.setattr(TaskManager, "_provider_get_tracked", read) + monkeypatch.setattr(provider, "update", update) + monkeypatch.setattr(TaskManager, "_reconcile_steering_for_rollback", reconcile) + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + retry_payload = payload(restore_id="restore-2" if new_restore else "restore-1") + if new_restore: + retry_payload["session_context"]["session_env_overrides"]["FOUNDRY_AGENT_SESSION_GUID"] = "b" * 32 + if repeat_override: + retry_payload["session_context"]["session_env_overrides"]["FOUNDRY_PROJECT_ENDPOINT"] = failed_endpoint + first_payload = payload() + first_payload["session_context"]["session_env_overrides"]["FOUNDRY_PROJECT_ENDPOINT"] = failed_endpoint + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test" + ) as http, + ): + try: + failed = await http.post("/_agent/after-restore", json=first_payload) + assert failed.status_code == 500 + assert failed.json() == { + "error": {"code": "after_restore_failed", "message": "The after-restore hook failed."} + } + stopped = app._task_manager + assert stopped is not None and stopped._shutdown_event.is_set() + assert stopped._pending_shutdown_tasks + rollback = app._lifecycle.pending_rollback + assert rollback is not None + assert rollback.environment["FOUNDRY_PROJECT_ENDPOINT"] == captured_endpoint + assert rollback.config["project_endpoint"] == captured_endpoint + assert drain_cancelled.is_set() + assert not queued.is_set() + for _ in range(failures - 1): + failed = await http.post("/_agent/after-restore", json=retry_payload) + assert failed.status_code == 500 + assert failed.json()["error"]["code"] == "after_restore_failed" + assert app._task_manager is get_task_manager() is stopped + assert app._lifecycle.pending_rollback is rollback + assert callbacks == ["restore-1"] + if retry_mode == "shutdown": + await app._shutdown_task_manager() + elif retry_mode == "cancel": + block_reconciliation = True + cleanup_request = asyncio.create_task(http.post("/_agent/after-restore", json=retry_payload)) + await asyncio.wait_for(cleanup_entered.wait(), 2) + cleanup_request.cancel() + await asyncio.sleep(0) + assert not cleanup_request.done() + cleanup_request.cancel() + release_cleanup.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(cleanup_request, 2) + block_reconciliation = False + assert os.environ["FOUNDRY_PROJECT_ENDPOINT"] == app.config.project_endpoint == captured_endpoint + assert app._lifecycle.pending_rollback is None + if retry_mode != "restore": + assert app._task_manager is None + assert not stopped._has_live_workers + assert callbacks == ["restore-1"] + response = await http.post("/_agent/after-restore", json=retry_payload) + assert response.status_code == 200, response.text + assert app._lifecycle.pending_rollback is None + await asyncio.wait_for(queued.wait(), 2) + assert remaining_failures == 0 + assert get_task_manager() is app._task_manager is not stopped + assert not stopped._has_live_workers + assert not stopped._pending_shutdown_tasks + assert observed == [{"value": "saved-input"}, {"value": "queued-input"}] + assert callbacks == ["restore-1", retry_payload["session_context"]["restore_id"]] + stored = await provider.get("existing-task") + assert stored is not None and stored.payload is not None + assert stored.payload["input"] == {"value": "queued-input"} + assert stored.payload["steering"]["pending_inputs"] == [{"value": "later-input"}] + assert (await http.post("/_agent/after-restore", json=retry_payload)).status_code == 200 + assert len(callbacks) == 2 + assert len(observed) == 2 + assert scans == 2 + assert factory.call_count == 2 + later_payload = payload(restore_id="restore-3") + later_payload["session_context"]["session_env_overrides"][ + "FOUNDRY_AGENT_SESSION_GUID" + ] = app.config.session_guid + later_response = await http.post("/_agent/after-restore", json=later_payload) + assert later_response.status_code == 200, later_response.text + assert os.environ["FOUNDRY_PROJECT_ENDPOINT"] == app.config.project_endpoint == captured_endpoint + assert len(callbacks) == 3 + assert len(observed) == 2 + assert scans == 3 + assert factory.call_count == 2 + finally: + release_cleanup.set() + finish.set() + if cleanup_request is not None: + if not cleanup_request.done(): + cleanup_request.cancel() + await asyncio.gather(cleanup_request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_http_timeout_continues_activation_and_retry_deduplicates( + provider_factory: tuple[LocalFileTaskProvider, Mock], +) -> None: + from hypercorn.asyncio import serve + from hypercorn.config import Config + + _, factory = provider_factory + app = host() + callback_started = asyncio.Event() + calls = 0 + + @app.after_restore_handler + async def dependencies(context: AgentSessionContext) -> None: + nonlocal calls + assert context.restore_id == "restore-1" + calls += 1 + callback_started.set() + await asyncio.sleep(0.4) + + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + config = Config() + config.bind = [f"127.0.0.1:{port}"] + config.accesslog = None + config.errorlog = None + config.graceful_timeout = 1 + stop = asyncio.Event() + server = asyncio.create_task(serve(app, config, shutdown_trigger=stop.wait)) + try: + async with httpx.AsyncClient(base_url=f"http://127.0.0.1:{port}", timeout=2) as http: + for _ in range(100): + if server.done(): + await server + pytest.fail("HTTP server stopped before readiness") + try: + ready = await http.get("/readiness") + except httpx.ConnectError: + await asyncio.sleep(0.01) + else: + assert ready.status_code == 200 + break + else: + pytest.fail("HTTP server did not become ready") + with pytest.raises(httpx.ReadTimeout): + await http.post("/_agent/after-restore", json=payload(), timeout=0.05) + assert callback_started.is_set() + retry = await http.post("/_agent/after-restore", json=payload()) + assert retry.status_code == 200, retry.text + assert calls == 1 + assert get_task_manager()._startup_phases["ready"].completed + factory.assert_called_once() + finally: + stop.set() + await asyncio.wait_for(server, 5) + + +class _OwnedCredential: + """Async credential double recording close attempts by its owner.""" + + def __init__(self) -> None: + self.closed = 0 + self.close_failures: list[BaseException] = [] + + async def get_token(self, *_scopes: str, **_kwargs: Any) -> Any: + raise AssertionError("Hosted task requests are routed to a local store") + + async def close(self) -> None: + if self.close_failures: + raise self.close_failures.pop(0) + self.closed += 1 + + +async def open_pipeline_session(provider: Any) -> Any: + """Open the provider's real aiohttp session without sending a request.""" + transport = provider._client._pipeline._transport + await transport.open() + return transport.session + + +def hosted_runtime( + monkeypatch: pytest.MonkeyPatch, store: LocalFileTaskProvider +) -> tuple[list[_OwnedCredential], list[tuple[str, Any]]]: + """Create real owned hosted providers whose task requests use a local store.""" + import azure.identity.aio as identity + + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + credentials: list[_OwnedCredential] = [] + events: list[tuple[str, Any]] = [] + + def create_credential() -> _OwnedCredential: + credentials.append(_OwnedCredential()) + return credentials[-1] + + monkeypatch.setattr(identity, "DefaultAzureCredential", create_credential) + for method in ("create", "get", "update", "delete", "list"): + + async def delegate(_provider: Any, *args: Any, _method: str = method, **kwargs: Any) -> Any: + return await getattr(store, _method)(*args, **kwargs) + + monkeypatch.setattr(HostedTaskProvider, method, delegate) + original_close = HostedTaskProvider.close + + async def close(provider: Any) -> None: + events.append(("close", provider)) + await original_close(provider) + + monkeypatch.setattr(HostedTaskProvider, "close", close) + monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "hosted") + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://captured.example.invalid/project") + return credentials, events + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", [RuntimeError("credential close failed"), asyncio.CancelledError()], ids=["exception", "cancel"] +) +async def test_owned_credential_close_failure_is_retained_for_retry(failure: BaseException) -> None: + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + credential = _OwnedCredential() + credential.close_failures.append(failure) + provider = HostedTaskProvider(project_endpoint="https://owned.example.invalid/project", credential=credential) + provider._owned_credential = credential + session = await open_pipeline_session(provider) + with pytest.raises(type(failure)): + await provider.close() + assert session.closed + assert provider._owned_credential is credential and credential.closed == 0 + await provider.close() + assert provider._owned_credential is None and credential.closed == 1 + + +@pytest.mark.asyncio +async def test_repeated_failed_restores_close_owned_hosted_providers_after_workers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + credentials, events = hosted_runtime(monkeypatch, LocalFileTaskProvider(tmp_path / "tasks")) + running = asyncio.Event() + managers: list[TaskManager] = [] + sessions: list[Any] = [] + app = host() + + @task(name="owned_provider_work") + async def work(ctx: TaskContext[str]) -> str: + provider = get_task_manager().provider + running.set() + try: + await asyncio.Event().wait() + return ctx.input + finally: + events.append(("finally", provider)) + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + managers.append(get_task_manager()) + sessions.append(await open_pipeline_session(managers[-1].provider)) + running.clear() + await work.start(task_id=f"owned-work-{len(managers)}", input=context.session_id) + await asyncio.wait_for(running.wait(), 2) + raise RuntimeError("Fail restore while owned work is running") + + async with app.router.lifespan_context(app), client(app) as http: + for _ in range(3): + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 500, response.text + assert app._task_manager is None + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + providers = [manager.provider for manager in managers] + assert len({id(provider) for provider in providers}) == 3 + # Each provider closes once, after its worker's cleanup and before it is discarded. + assert events == [event for provider in providers for event in (("finally", provider), ("close", provider))] + assert all(session.closed for session in sessions) + assert [credential.closed for credential in credentials] == [1, 1, 1] + assert all(not manager._has_live_workers for manager in managers) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "cancel"]) +async def test_failed_owned_close_retains_rollback_runtime_until_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + credentials, _ = hosted_runtime(monkeypatch, LocalFileTaskProvider(tmp_path / "tasks")) + managers: list[TaskManager] = [] + app = host() + + @app.after_restore_handler + async def initialize(context: AgentSessionContext) -> None: + managers.append(get_task_manager()) + if len(managers) == 1: + credentials[0].close_failures.append( + RuntimeError("credential close failed") if failure == "exception" else asyncio.CancelledError() + ) + raise RuntimeError("Fail restore before cleanup") + + async with app.router.lifespan_context(app), client(app) as http: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 500 + stale = managers[0] + assert app._task_manager is stale + assert app._lifecycle.pending_rollback is not None + assert stale._shutdown_event.is_set() and not stale._provider_closed + assert stale.provider._owned_credential is credentials[0] + assert credentials[0].closed == 0 + response = await http.post("/_agent/after-restore", json=payload()) + assert response.status_code == 200, response.text + assert credentials[0].closed == 1 + assert stale._provider_closed and stale.provider._owned_credential is None + assert get_task_manager() is managers[1] and managers[1] is not stale + assert app._lifecycle.pending_rollback is None + + +@pytest.mark.asyncio +async def test_ordinary_shutdown_closes_owned_provider_after_cancelled_worker_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "false") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "cold-session") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + credentials, events = hosted_runtime(monkeypatch, LocalFileTaskProvider(tmp_path / "tasks")) + running, release = asyncio.Event(), asyncio.Event() + app = host() + + @task(name="ordinary_owned_cleanup") + async def work(ctx: TaskContext[str]) -> str: + provider = get_task_manager().provider + running.set() + try: + await asyncio.Event().wait() + return ctx.input + except asyncio.CancelledError: + await release.wait() + await provider.get(ctx.task_id) + events.append(("cleanup", provider)) + raise + + async with app.router.lifespan_context(app): + manager = get_task_manager() + session = await open_pipeline_session(manager.provider) + await work.start(task_id="ordinary-owned-cleanup", input="saved") + await asyncio.wait_for(running.wait(), 2) + # Ordinary shutdown returned without joining the cancelled worker or closing its provider. + assert app._task_manager is None + assert manager._has_live_task_work + assert events == [] and not session.closed + release.set() + assert manager._provider_closer is not None + await asyncio.wait_for(asyncio.shield(manager._provider_closer), 2) + assert events == [("cleanup", manager.provider), ("close", manager.provider)] + assert session.closed and credentials[0].closed == 1 + + +@pytest.mark.asyncio +async def test_injected_hosted_provider_and_credential_remain_caller_owned() -> None: + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + credential = _OwnedCredential() + provider = HostedTaskProvider(project_endpoint="https://caller.example.invalid/project", credential=credential) + session = await open_pipeline_session(provider) + manager = TaskManager(config=host().config, provider=provider, shutdown_grace_seconds=0) + await manager.shutdown(wait_for_workers=True) + await manager._close_owned_provider() + await manager._close_owned_provider_after_shutdown() + assert not session.closed + await provider.close() + assert session.closed and credential.closed == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("close_fails", [False, True]) +async def test_inactive_replacement_closes_old_owned_provider_before_publishing_new( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, close_fails: bool +) -> None: + from azure.ai.agentserver.core.tasks._client import HostedTaskProvider + + captured = "https://captured.example.invalid/project" + replacement_endpoint = "https://replacement.example.invalid/project" + credentials, _ = hosted_runtime(monkeypatch, LocalFileTaskProvider(tmp_path / "tasks")) + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "session-1") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + original_recovery = TaskManager._recover_stale_tasks + scans = 0 + + async def recovery(manager: TaskManager) -> None: + nonlocal scans + scans += 1 + if scans == 1: + raise RuntimeError("Interrupted essential initialization") + await original_recovery(manager) + + monkeypatch.setattr(TaskManager, "_recover_stale_tasks", recovery) + app = host() + closing: list[tuple[Any, Any, Any]] = [] + patched_close = HostedTaskProvider.close + + async def close(provider: Any) -> None: + closing.append((provider, app._task_manager, _manager._manager)) + await patched_close(provider) + + monkeypatch.setattr(HostedTaskProvider, "close", close) + changed = payload() + changed["session_context"]["session_env_overrides"]["FOUNDRY_PROJECT_ENDPOINT"] = replacement_endpoint + async with app.router.lifespan_context(app), client(app) as http: + assert (await http.post("/_agent/after-restore", json=payload())).status_code == 500 + stale = app._task_manager + assert stale is not None and not stale._has_live_workers + retry = changed + if close_fails: + credentials[0].close_failures.append(RuntimeError("credential close failed")) + assert (await http.post("/_agent/after-restore", json=changed)).status_code == 500 + # The stopped manager stays referenced for retry, and no replacement was published. + assert app._task_manager is stale and stale._shutdown_event.is_set() + assert stale.provider._owned_credential is credentials[0] and len(credentials) == 1 + # A compatible retry must still dispose of the stopped manager instead of reusing it. + retry = payload() + response = await http.post("/_agent/after-restore", json=retry) + assert response.status_code == 200, response.text + replacement = get_task_manager() + assert replacement is app._task_manager and replacement is not stale + endpoint = captured if close_fails else replacement_endpoint + assert replacement.provider._base_url == f"{endpoint}/tasks" + assert closing == [(stale.provider, stale, None)] * (2 if close_fails else 1) + assert [credential.closed for credential in credentials] == [1, 0] + assert closing[-1] == (replacement.provider, None, None) + assert [credential.closed for credential in credentials] == [1, 1] diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py index 5e7826dbc904..03b1a5f5347b 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py @@ -4,6 +4,7 @@ """Tests for tracing configuration — not invocation spans (those live in the invocations package).""" import asyncio +import logging import os from concurrent.futures import ThreadPoolExecutor from functools import partial @@ -13,6 +14,9 @@ import pytest from opentelemetry import baggage as _otel_baggage, context as _otel_context +from opentelemetry._logs import LogRecord +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult from opentelemetry.sdk.resources import Resource @@ -622,6 +626,25 @@ def test_agent_attrs_present_on_exported_span(self) -> None: assert attrs["gen_ai.agent.id"] == "my-agent:1.0" assert attrs["microsoft.foundry.project.id"] == "proj-123" + def test_microsoft_foundry_on_parent_and_child_spans(self) -> None: + """All spans get microsoft.foundry=True, and gen_ai.provider.name is left untouched.""" + provider, collector = self._create_provider(_FoundryEnrichmentSpanProcessor()) + tracer = provider.get_tracer("framework") + try: + with tracer.start_as_current_span("parent", attributes={"gen_ai.provider.name": "other-provider"}) as parent: + assert parent.attributes["microsoft.foundry"] == "True" + assert parent.attributes["gen_ai.provider.name"] == "other-provider" + with tracer.start_as_current_span("child") as child: + assert child.attributes["microsoft.foundry"] == "True" + child.set_attribute("microsoft.foundry", "framework-value") + parent.set_attribute("microsoft.foundry", "framework-value") + + assert len(collector.spans) == 2 + for span in collector.spans: + assert span.attributes["microsoft.foundry"] == "True" + finally: + provider.shutdown() + def test_agent_attrs_survive_framework_overwrite(self) -> None: """A framework setting agent attrs mid-span must not win.""" proc = _FoundryEnrichmentSpanProcessor( @@ -641,6 +664,26 @@ def test_agent_attrs_survive_framework_overwrite(self) -> None: assert attrs["gen_ai.agent.name"] == "my-agent" assert attrs["gen_ai.agent.id"] == "my-agent:1.0" + def test_a2a_span_preserves_agent_card_name_and_version(self) -> None: + proc = _FoundryEnrichmentSpanProcessor( + agent_name="foundry-deployment", + agent_version="7", + agent_id="foundry-deployment:7", + ) + provider, collector = self._create_provider(proc) + tracer = provider.get_tracer("Azure.AI.AgentServer.A2A") + + with tracer.start_as_current_span("SendMessage") as span: + span.set_attribute("gen_ai.agent.name", "Agent Card Name") + span.set_attribute("gen_ai.agent.version", "1.2.3") + span.set_attribute("microsoft.foundry", "framework-value") + + attrs = dict(collector.spans[0].attributes) + assert attrs["gen_ai.agent.name"] == "Agent Card Name" + assert attrs["gen_ai.agent.version"] == "1.2.3" + assert attrs["gen_ai.agent.id"] == "foundry-deployment:7" + assert attrs["microsoft.foundry"] == "True" + def test_blueprint_id_uses_correct_attribute_key(self) -> None: """agent_blueprint_id must be emitted under microsoft.a365.agent.blueprint.id.""" proc = _FoundryEnrichmentSpanProcessor( @@ -888,6 +931,47 @@ def __init__(self, attributes): class TestBaggageLogRecordProcessor: + @pytest.mark.parametrize("attributes", [None, {}, {"microsoft.foundry": "other-value"}]) + @pytest.mark.parametrize("baggage_provider", [None, "baggage-value"]) + def test_microsoft_foundry_on_all_logs(self, attributes, baggage_provider) -> None: + proc = _BaggageLogRecordProcessor() + log_data = _FakeLogData(None if attributes is None else attributes.copy()) + ctx = _otel_context.Context() + if baggage_provider: + ctx = _otel_baggage.set_baggage("microsoft.foundry", baggage_provider, context=ctx) + token = _otel_context.attach(ctx) + try: + proc.on_emit(log_data) + finally: + _otel_context.detach(token) + + assert log_data.log_record.attributes["microsoft.foundry"] == "True" + + @pytest.mark.parametrize("attributes", [None, {}, {"microsoft.foundry": "other-value"}]) + def test_microsoft_foundry_on_exported_otel_and_python_logs(self, attributes) -> None: + collector = InMemoryLogRecordExporter() + provider = LoggerProvider() + provider.add_log_record_processor(_BaggageLogRecordProcessor()) + provider.add_log_record_processor(SimpleLogRecordProcessor(collector)) + handler = LoggingHandler(logger_provider=provider) + python_logger = logging.Logger("agentserver.test") + python_logger.addHandler(handler) + ctx = _otel_baggage.set_baggage("microsoft.foundry", "baggage-value") + token = _otel_context.attach(ctx) + try: + provider.get_logger("agentserver.test").emit(LogRecord(body="otel log", attributes=attributes)) + python_logger.warning("python log", extra=attributes) + + records = collector.get_finished_logs() + assert {record.log_record.body for record in records} == {"otel log", "python log"} + for record in records: + assert record.log_record.attributes["microsoft.foundry"] == "True" + finally: + _otel_context.detach(token) + python_logger.removeHandler(handler) + handler.close() + provider.shutdown() + def test_adds_agent_and_fallback_session_attributes(self) -> None: proc = _BaggageLogRecordProcessor( agent_name="agent-a", @@ -924,6 +1008,18 @@ def test_prefers_baggage_session_id_over_fallback(self) -> None: attrs = log_data.log_record.attributes assert attrs["microsoft.session.id"] == "session-from-baggage" + def test_refreshes_fallback_session_id_from_environment(self) -> None: + proc = _BaggageLogRecordProcessor(session_id="captured-session") + log_data = _FakeLogData({}) + + with mock.patch.dict( + os.environ, + {"FOUNDRY_AGENT_SESSION_ID": "restored-session"}, + ): + proc.on_emit(log_data) + + assert log_data.log_record.attributes["microsoft.session.id"] == "restored-session" + def test_does_not_overwrite_existing_log_attributes(self) -> None: proc = _BaggageLogRecordProcessor( agent_name="agent-a", diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md index 0adbd7ac5a56..a86c63a306fd 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md @@ -1,5 +1,45 @@ # Release History +## 1.3.0 (2026-10-29) + +### Other Changes + +- Stable release of the 1.3.0 preview series, including the experimental + Voice route and protocol-scoped task activation. No implementation changes + since 1.3.0b1. +- Require the stable `azure-ai-agentserver-core>=2.3.0` release. Publish Core + before Invocations. + +## 1.3.0b1 (2026-10-26) + +### Features Added + +- Capture invocation-local original-subject assertions for HTTP, WebSocket and + typed Voice handlers. Model-header propagation is an explicit per-call opt-in. +- Added immutable `InputDataPart` and `ResponseOutputDataDone` for structured application data in Bridge protocol `1.0`. Existing callbacks and explicit `ResponseNone` silent completion remain application-owned. +- Added a standalone `/voice_conversation_engine` WebSocket route to the experimental `VoiceAgentServerHost`, reusing the existing typed Bridge event relay alongside `/invocations_ws`. Existing `invocations_ws` behavior and generic `InvocationAgentServerHost` raw WebSocket handlers are unchanged. +- Aligned the experimental Voice caller-interruption contract with `user.barge_in`, `UserBargeIn`, `UserBargeInCallback`, and `VoiceAgentServerHost.on_user_barge_in`, preserving playback snapshots and application-owned cancellation/history. Voice Live senders must use the same event contract. + +### Bugs Fixed + +- Reject malformed platform attribution before binding HTTP context, and + finalize WebSocket connections with a classified error on invalid metadata. +- Upgrade both typed Voice WebSocket endpoints before closing with code `1011` + on malformed platform attribution, instead of rejecting the HTTP handshake. +- Apply the Invocations task-runtime switch to both typed Voice WebSocket + endpoints, including composed Voice/Responses hosts where Responses + independently enables the task manager. + +### Other Changes + +- Raised the minimum `azure-ai-agentserver-core` dependency to `2.3.0b2` for + protocol-scoped task-runtime support and original-subject attribution. + Publish that Core version before this package. +- Invocations continues to opt into resilient tasks with + `set_resilient_tasks_enabled(True)` before host startup. Responses + protocol-scoped task-runtime activation does not change that global switch, + so enabling Responses does not implicitly activate Invocations. + ## 1.2.0 (2026-09-29) ### Other Changes diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/README.md b/sdk/agentserver/azure-ai-agentserver-invocations/README.md index 51c27f4aca30..738f40cb7529 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/README.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/README.md @@ -4,7 +4,7 @@ The `azure-ai-agentserver-invocations` package provides the invocation protocol - **HTTP** (`invocations` protocol) — `POST /invocations`, `GET /invocations/{id}`, `POST /invocations/{id}/cancel`, `GET /invocations/docs/openapi.json`, `GET /invocations/docs/asyncapi.{json,yaml}`. - **WebSocket** (`invocations_ws` protocol) — full-duplex streaming at `/invocations_ws`, registered with `@app.ws_handler`. -- **Voice Live Bridge** — an experimental typed event relay in the `azure.ai.agentserver.invocations.voice` submodule, layered on `invocations_ws`. +- **Voice Live Bridge** — an experimental typed event relay in the `azure.ai.agentserver.invocations.voice` submodule, available at both `/voice_conversation_engine` and the existing `/invocations_ws` endpoint. ## Getting started @@ -308,9 +308,23 @@ The handler receives a Starlette [`WebSocket`][starlette-ws] and returns `None`. ## Typed Voice Live Bridge submodule (preview) -`VoiceAgentServerHost` provides typed `on_` decorators over the existing -`invocations_ws` transport. Each callback receives an immutable event and a -send-only `Session`: +`VoiceAgentServerHost` provides typed `on_` decorators at both `/invocations_ws` and `/voice_conversation_engine`, using the same `VoiceAgentServerHost()` construction. Existing legacy protocol declarations and metadata do not need migration. To use standalone CE, declare `voice_conversation_engine` version `1.0.0` on the deployed hosted agent. Neither legacy Bridge metadata flag is required for CE. The SDK does not create or modify hosted agent protocol declarations; registering the container route alone does not enable the public Foundry CE endpoint. + +The Voice Agent configuration field `conversation_engine` is separate from the Hosted Agent protocol name and remains unchanged. + +Both endpoints reuse the same typed Bridge messages, callbacks, transport limits, +tracing, and application-owned lifecycle. The message handshake remains +`session.start.protocol_version="1.0"`, independent of the container protocol +version `1.0.0`. The generic `InvocationAgentServerHost.ws_handler` and independent +`invocations_ws` routes are unchanged. The typed host reserves both paths, applying +the existing same-scope conflict detection and Host/Mount authority rules to each. +Use a locally built SDK containing CE support +until a release containing it is available; enabling CE also requires the Foundry +CE route and CE-aware Voice Live to be deployed. + +Each callback receives an immutable event and a send-only `Session`: + +Caller interruptions use `user.barge_in`, the `UserBargeIn` event model, `UserBargeInCallback`, and `@app.on_user_barge_in`. Voice Live senders must use the same event contract. Generic raw WebSocket handlers and application-owned cancellation/history are unchanged. ```python import asyncio @@ -417,6 +431,8 @@ reconnect state. ### Voice tracing +Bridge protocol remains `1.0`. `UserMessage.content` contains ordered `InputTextPart` and `InputDataPart` values. `InputDataPart.data` is a deeply immutable application JSON object. Applications can send `ResponseOutputDataDone(response_id=..., item_id=..., data=...)` after `ResponseCreated`, then explicitly finish with `ResponseDone`. Each data item is complete in one frame and cannot interrupt an unfinished text item; it produces no speech. After a successful operation requiring no output, explicitly send `ResponseNone(in_reply_to=(event.item_id,), reason="acknowledged")` only after required persistence finishes. The relay never infers business completion from callback return. Update both the Bridge and SDK before using these additions; there is no version change or runtime negotiation. + The typed Voice endpoint extracts W3C context from each WebSocket upgrade and creates one `agentserver.connection` span for the physical connection. Each registered event dispatch creates a sibling `voice.callback` span. Application @@ -477,7 +493,7 @@ code catches cancellation and returns normally, recovery is outside the SDK contract; the SDK does not forcibly terminate or retain that callback. For full-duplex streaming, the agent creates and owns a generation task, returns -from `on_user_message`, and cancels that task from `on_barge_in`, +from `on_user_message`, and cancels that task from `on_user_barge_in`, `on_response_cancelled`, `on_response_timeout`, or `on_connection_terminating`. Each task remains responsible for its own asynchronous resource cleanup. See the complete @@ -488,7 +504,7 @@ sample. | Environment variable | Default | Description | |---|---|---| -| `SSE_KEEPALIVE_INTERVAL` | unset (disabled) | Platform-injected interval, in seconds, for SSE comments on idle `POST /invocations` streams. `0` disables keep-alive. Surfaced on `app.config.sse_keepalive_interval`. | +| `SSE_KEEPALIVE_INTERVAL` | `15` | Interval, in seconds, for SSE comments on idle `POST /invocations` streams. `0` disables keep-alive. Surfaced on `app.config.sse_keepalive_interval`. | | `WS_KEEPALIVE_INTERVAL` | unset (disabled) | Platform-injected WebSocket Ping interval, in seconds. `0` disables keep-alive. Surfaced on `app.config.ws_ping_interval` and wired into Hypercorn's `websocket_ping_interval` by `AgentServerHost`. | ### Reference: close codes diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/api.md b/sdk/agentserver/azure-ai-agentserver-invocations/api.md index 983c360ddca3..abebbbf084ed 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/api.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/api.md @@ -76,13 +76,12 @@ namespace azure.ai.agentserver.invocations.voice @experimental @dataclass(frozen=True, kw_only=True, repr=False) - class azure.ai.agentserver.invocations.voice.BargeIn(_InboundMessage): - heard_text: str + class azure.ai.agentserver.invocations.voice.EndCall(_OutboundMessage): id: str - item_id: Optional[str] - response_id: str + mode: EndCallMode = EndCallMode.DRAIN + reason: str ts: str - type: ClassVar[str] = barge_in + type: ClassVar[str] = end_call def __delattr__() -> None: ... @@ -91,11 +90,10 @@ namespace azure.ai.agentserver.invocations.voice def __hash__() -> None: ... def __init__( - id: str, - ts: str, - response_id: str, - heard_text: str, - item_id: str | None = None + id: str = ..., + ts: str = ..., + reason: str, + mode: EndCallMode = ... ) -> None: ... def __setattr__() -> None: ... @@ -103,14 +101,17 @@ namespace azure.ai.agentserver.invocations.voice def _voice_model_repr(self: Any) -> str: ... + @experimental + class azure.ai.agentserver.invocations.voice.EndCallMode(str, Enum, metaclass=CaseInsensitiveEnumMeta): + DRAIN = "drain" + IMMEDIATE = "immediate" + + @experimental @dataclass(frozen=True, kw_only=True, repr=False) - class azure.ai.agentserver.invocations.voice.EndCall(_OutboundMessage): - id: str - mode: EndCallMode = EndCallMode.DRAIN - reason: str - ts: str - type: ClassVar[str] = end_call + class azure.ai.agentserver.invocations.voice.InputDataPart: + data: Mapping[str, Any] + type: Literal["input_data"] = input_data def __delattr__() -> None: ... @@ -118,24 +119,15 @@ namespace azure.ai.agentserver.invocations.voice def __hash__() -> None: ... - def __init__( - id: str = ..., - ts: str = ..., - reason: str, - mode: EndCallMode = ... - ) -> None: ... + def __init__(data: Mapping) -> None: ... + + def __post_init__(self) -> None: ... def __setattr__() -> None: ... def _voice_model_repr(self: Any) -> str: ... - @experimental - class azure.ai.agentserver.invocations.voice.EndCallMode(str, Enum, metaclass=CaseInsensitiveEnumMeta): - DRAIN = "drain" - IMMEDIATE = "immediate" - - @experimental @dataclass(frozen=True, kw_only=True, repr=False) class azure.ai.agentserver.invocations.voice.InputTextPart: @@ -346,6 +338,37 @@ namespace azure.ai.agentserver.invocations.voice def _voice_model_repr(self: Any) -> str: ... + @experimental + @dataclass(frozen=True, kw_only=True, repr=False) + class azure.ai.agentserver.invocations.voice.ResponseOutputDataDone(_OutboundMessage): + data: Mapping[str, Any] + id: str + item_id: str + response_id: str + ts: str + type: ClassVar[str] = response.output_data.done + + def __delattr__() -> None: ... + + def __eq__() -> None: ... + + def __hash__() -> None: ... + + def __init__( + id: str = ..., + ts: str = ..., + response_id: str, + item_id: str, + data: Mapping + ) -> None: ... + + def __post_init__(self) -> None: ... + + def __setattr__() -> None: ... + + def _voice_model_repr(self: Any) -> str: ... + + @experimental @dataclass(frozen=True, kw_only=True, repr=False) class azure.ai.agentserver.invocations.voice.ResponseOutputTextDelta(_OutboundMessage): @@ -662,10 +685,39 @@ namespace azure.ai.agentserver.invocations.voice TRANSPORT_ERROR = "transport_error" + @experimental + @dataclass(frozen=True, kw_only=True, repr=False) + class azure.ai.agentserver.invocations.voice.UserBargeIn(_InboundMessage): + heard_text: str + id: str + item_id: Optional[str] + response_id: str + ts: str + type: ClassVar[str] = user.barge_in + + def __delattr__() -> None: ... + + def __eq__() -> None: ... + + def __hash__() -> None: ... + + def __init__( + id: str, + ts: str, + response_id: str, + heard_text: str, + item_id: str | None = None + ) -> None: ... + + def __setattr__() -> None: ... + + def _voice_model_repr(self: Any) -> str: ... + + @experimental @dataclass(frozen=True, kw_only=True, repr=False) class azure.ai.agentserver.invocations.voice.UserMessage(_InboundMessage): - content: tuple[InputTextPart, Ellipsis] + content: tuple[Union[InputTextPart, InputDataPart], Ellipsis] id: str item_id: str ts: str @@ -769,8 +821,6 @@ namespace azure.ai.agentserver.invocations.voice def invoke_handler(self, fn: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]: ... - def on_barge_in(self, callback: BargeInCallback) -> BargeInCallback: ... - def on_connection_terminating(self, callback: ConnectionTerminatingCallback) -> ConnectionTerminatingCallback: ... def on_disconnect(self, callback: DisconnectCallback) -> DisconnectCallback: ... @@ -787,6 +837,8 @@ namespace azure.ai.agentserver.invocations.voice def on_session_start(self, callback: SessionStartCallback) -> SessionStartCallback: ... + def on_user_barge_in(self, callback: UserBargeInCallback) -> UserBargeInCallback: ... + def on_user_message(self, callback: UserMessageCallback) -> UserMessageCallback: ... def on_user_no_input(self, callback: UserNoInputCallback) -> UserNoInputCallback: ... diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-invocations/api.metadata.yml index 964e520673bb..568739a7946f 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-invocations/api.metadata.yml @@ -1,4 +1,4 @@ -apiMdSha256: e4e11fea9355277129dc5bea863e6c2252d8df0028b9e30dd2a29a53811389b1 -packageVersion: 1.2.0 +apiMdSha256: b1f36470262c47fc429f51e3da3e305baba09a19bd481573a3aa5f497c99f6e4 +packageVersion: 1.3.0 parserVersion: 0.3.31 pythonVersion: 3.11.15 diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py index d1d58ae697c0..59529ed448fd 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py @@ -23,6 +23,7 @@ from azure.ai.agentserver.core import ( # pylint: disable=no-name-in-module AgentServerHost, FoundryAgentRequestContext, + FoundryOriginalSubject, create_error_response, reset_request_context, set_request_context, @@ -35,6 +36,8 @@ PLATFORM_ERROR_TAG, USER_ID, ) +from azure.ai.agentserver.core._middleware import _task_runtime_middleware +from azure.ai.agentserver.core.tasks import resilient_tasks_enabled from ._constants import InvocationConstants from ._invocation_ws import _WSHandlerMixin @@ -88,6 +91,7 @@ def _classify_error(exc: BaseException) -> tuple[str, Optional[str]]: return _ERROR_SOURCE_PLATFORM, detail return _ERROR_SOURCE_UPSTREAM, None + # Maximum length and allowed characters for user-provided IDs (defense in depth). _MAX_ID_LENGTH = 256 _VALID_ID_RE = re.compile(r"^[a-zA-Z0-9\-_.:]+$") @@ -167,6 +171,24 @@ def _platform_context_from_request( call_id=request.state.call_id or None, user_id=request.state.user_id or None, session_id=session_id or None, + original_subject=FoundryOriginalSubject.from_headers(request.headers), + ) + + +def _invalid_original_subject_response(headers: dict[str, str]) -> Response: + """Reject malformed platform attribution without exposing its contents. + + :param headers: Response headers to preserve alongside the platform error classification. + :type headers: dict[str, str] + :return: A classified bad-request response. + :rtype: ~starlette.responses.Response + """ + logger.warning("Request rejected: invalid original-subject platform header.") + return create_error_response( + "invalid_request", + "Invalid original-subject platform header.", + status_code=400, + headers=_apply_error_source_headers(headers, _ERROR_SOURCE_PLATFORM, "invalid_original_subject"), ) @@ -238,13 +260,9 @@ def __init__( # preserve backward-compat with existing callers passing dict-like # Mapping subclasses; consider unifying in a future release. if asyncapi_spec_json is not None and not isinstance(asyncapi_spec_json, dict): - raise TypeError( - f"asyncapi_spec_json must be dict, got {type(asyncapi_spec_json).__name__}" - ) + raise TypeError(f"asyncapi_spec_json must be dict, got {type(asyncapi_spec_json).__name__}") if asyncapi_spec_yaml is not None and not isinstance(asyncapi_spec_yaml, str): - raise TypeError( - f"asyncapi_spec_yaml must be str, got {type(asyncapi_spec_yaml).__name__}" - ) + raise TypeError(f"asyncapi_spec_yaml must be str, got {type(asyncapi_spec_yaml).__name__}") self._openapi_spec = openapi_spec self._asyncapi_spec_json = asyncapi_spec_json self._asyncapi_spec_yaml = asyncapi_spec_yaml @@ -253,6 +271,7 @@ def __init__( # interval lives on ``AgentConfig`` and is wired into Hypercorn # by ``AgentServerHost._build_hypercorn_config``). self._init_ws_state() + task_middleware = _task_runtime_middleware(self, kwargs, resilient_tasks_enabled) # Build invocation routes and pass to parent via routes kwarg invocation_routes: list[Any] = [ @@ -279,18 +298,21 @@ def __init__( self._create_invocation_endpoint, methods=["POST"], name="create_invocation", + middleware=task_middleware, ), Route( "/invocations/{invocation_id}", self._get_invocation_endpoint, methods=["GET"], name="get_invocation", + middleware=task_middleware, ), Route( "/invocations/{invocation_id}/cancel", self._cancel_invocation_endpoint, methods=["POST"], name="cancel_invocation", + middleware=task_middleware, ), ] @@ -311,9 +333,7 @@ def __init__( # Handler decorators # ------------------------------------------------------------------ - def invoke_handler( - self, fn: Callable[[Request], Awaitable[Response]] - ) -> Callable[[Request], Awaitable[Response]]: + def invoke_handler(self, fn: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]: """Register a function as the invoke handler. Usage:: @@ -381,15 +401,14 @@ def cancel_invocation_handler( async def _dispatch_invoke(self, request: Request) -> Response: if self._invoke_fn is not None: return await self._invoke_fn(request) - raise NotImplementedError( - "No invoke handler registered. Use the @invocations.invoke_handler decorator." - ) + raise NotImplementedError("No invoke handler registered. Use the @invocations.invoke_handler decorator.") async def _dispatch_get_invocation(self, request: Request) -> Response: if self._get_invocation_fn is not None: return await self._get_invocation_fn(request) return create_error_response( - "not_found", "get_invocation not implemented", + "not_found", + "get_invocation not implemented", status_code=404, headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), ) @@ -398,7 +417,8 @@ async def _dispatch_cancel_invocation(self, request: Request) -> Response: if self._cancel_invocation_fn is not None: return await self._cancel_invocation_fn(request) return create_error_response( - "not_found", "cancel_invocation not implemented", + "not_found", + "cancel_invocation not implemented", status_code=404, headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), ) @@ -427,7 +447,8 @@ async def _get_openapi_spec_endpoint(self, request: Request) -> Response: # pyl spec = self.get_openapi_spec() if spec is None: return create_error_response( - "not_found", "No OpenAPI spec registered", + "not_found", + "No OpenAPI spec registered", status_code=404, headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), ) @@ -437,7 +458,8 @@ async def _get_asyncapi_spec_json_endpoint(self, request: Request) -> Response: spec = self.get_asyncapi_spec_json() if spec is None: return create_error_response( - "not_found", "No AsyncAPI (JSON) spec registered", + "not_found", + "No AsyncAPI (JSON) spec registered", status_code=404, headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), ) @@ -447,7 +469,8 @@ async def _get_asyncapi_spec_yaml_endpoint(self, request: Request) -> Response: spec = self.get_asyncapi_spec_yaml() if spec is None: return create_error_response( - "not_found", "No AsyncAPI (YAML) spec registered", + "not_found", + "No AsyncAPI (YAML) spec registered", status_code=404, headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), ) @@ -480,16 +503,16 @@ async def _wrapped_body() -> AsyncIterator[Any]: # Re-establish the invocation context for the streaming task. stream_inv_token = _invocation_id_var.set(invocation_id) stream_session_token = _session_id_var.set(session_id) - stream_ctx_token = ( - set_request_context(platform_context) if platform_context is not None else None - ) + stream_ctx_token = set_request_context(platform_context) if platform_context is not None else None try: async for chunk in original_iterator: yield chunk except Exception as exc: # pylint: disable=broad-exception-caught logger.error( "Error processing invocation %s: %s", - invocation_id, exc, exc_info=True, + invocation_id, + exc, + exc_info=True, ) # Record the exception on the current span. span = trace.get_current_span() @@ -527,15 +550,27 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: ) request.state.session_id = session_id + try: + platform_ctx = _platform_context_from_request(request, session_id) + except ValueError: + return _invalid_original_subject_response({ + InvocationConstants.INVOCATION_ID_HEADER: invocation_id, + InvocationConstants.SESSION_ID_HEADER: session_id, + }) + # Incoming baggage and trace context are already attached by # BaggageMiddleware and the Starlette OTel instrumentor. # Add protocol-specific baggage entries for this invocation. ctx = _otel_context.get_current() ctx = _otel_baggage.set_baggage( - "azure.ai.agentserver.invocation_id", invocation_id, context=ctx, + "azure.ai.agentserver.invocation_id", + invocation_id, + context=ctx, ) ctx = _otel_baggage.set_baggage( - "azure.ai.agentserver.session_id", session_id, context=ctx, + "azure.ai.agentserver.session_id", + session_id, + context=ctx, ) baggage_token = _otel_context.attach(ctx) @@ -545,7 +580,6 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: session_token = _session_id_var.set(session_id) # Bind platform context so outbound 1P calls (and handler/tool code) can # forward the per-request call ID and user ID (protocol 2.0.0). - platform_ctx = _platform_context_from_request(request, session_id) ctx_token = set_request_context(platform_ctx) try: response = await self._dispatch_invoke(request) @@ -596,9 +630,7 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: # Wrap streaming response body so exceptions during iteration are # recorded on the current trace span and logged as invocation errors. if isinstance(response, StreamingResponse): - response = self._wrap_streaming_response( - response, invocation_id, session_id, platform_ctx - ) + response = self._wrap_streaming_response(response, invocation_id, session_id, platform_ctx) return response @@ -629,10 +661,15 @@ async def _traced_invocation_endpoint( session_id = _sanitize_id(raw_session_id, "") if raw_session_id else "" request.state.session_id = session_id + try: + platform_ctx = _platform_context_from_request(request, session_id) + except ValueError: + return _invalid_original_subject_response({InvocationConstants.INVOCATION_ID_HEADER: invocation_id}) + _ensure_log_filter() inv_token = _invocation_id_var.set(invocation_id) session_token = _session_id_var.set(session_id) - ctx_token = set_request_context(_platform_context_from_request(request, session_id)) + ctx_token = set_request_context(platform_ctx) try: response = await dispatch(request) response.headers[InvocationConstants.INVOCATION_ID_HEADER] = invocation_id @@ -656,11 +693,7 @@ async def _traced_invocation_endpoint( reset_request_context(ctx_token) async def _get_invocation_endpoint(self, request: Request) -> Response: - return await self._traced_invocation_endpoint( - request, "get_invocation", self._dispatch_get_invocation - ) + return await self._traced_invocation_endpoint(request, "get_invocation", self._dispatch_get_invocation) async def _cancel_invocation_endpoint(self, request: Request) -> Response: - return await self._traced_invocation_endpoint( - request, "cancel_invocation", self._dispatch_cancel_invocation - ) + return await self._traced_invocation_endpoint(request, "cancel_invocation", self._dispatch_cancel_invocation) diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation_ws.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation_ws.py index 2eb7ce28053c..00de29b9bd61 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation_ws.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation_ws.py @@ -31,7 +31,12 @@ from azure.ai.agentserver.core import ( # pylint: disable=no-name-in-module AgentServerHost, + FoundryAgentRequestContext, + FoundryOriginalSubject, + reset_request_context, + set_request_context, ) +from azure.ai.agentserver.core.platform_headers import FOUNDRY_CALL_ID, USER_ID from ._constants import InvocationsWSConstants @@ -168,6 +173,12 @@ def _ensure_ws_route_registered(self) -> None: until ``@ws_handler`` is called is safe. """ from starlette.routing import WebSocketRoute # pylint: disable=import-outside-toplevel + from azure.ai.agentserver.core._middleware import ( # pylint: disable=import-outside-toplevel + _task_access_middleware, + ) + from azure.ai.agentserver.core.tasks import ( # pylint: disable=import-outside-toplevel + resilient_tasks_enabled, + ) for route in self.router.routes: if isinstance(route, WebSocketRoute) and getattr(route, "path", None) == InvocationsWSConstants.ROUTE_PATH: @@ -177,6 +188,7 @@ def _ensure_ws_route_registered(self) -> None: InvocationsWSConstants.ROUTE_PATH, self._ws_endpoint, name="invocations_ws", + middleware=_task_access_middleware(self, resilient_tasks_enabled), ) ) @@ -221,13 +233,32 @@ async def _ws_endpoint(self, websocket: WebSocket) -> None: ) logger.error( "WebSocket accept failed for session %s: %s", - session_id, exc, exc_info=True, + session_id, + exc, + exc_info=True, ) return close_code: int = InvocationsWSConstants.CLOSE_NORMAL handler_exc: Optional[BaseException] = None + error_code: Optional[str] = None + context_token = None + # The platform dispatch binds one invocation to this connection. Never + # infer an original subject from arbitrary WebSocket message payloads. try: + try: + original_subject = FoundryOriginalSubject.from_headers(websocket.headers) + except ValueError: + close_code = InvocationsWSConstants.CLOSE_INTERNAL_ERROR + error_code = "invalid_original_subject" + logger.warning("WebSocket rejected: invalid original-subject platform header.") + return + context_token = set_request_context(FoundryAgentRequestContext( + call_id=websocket.headers.get(FOUNDRY_CALL_ID), + user_id=websocket.headers.get(USER_ID), + session_id=session_id, + original_subject=original_subject, + )) close_code, handler_exc = await self._invoke_user_handler(websocket, session_id) except BaseException as exc: # pylint: disable=broad-exception-caught # ``_invoke_user_handler`` catches ``Exception`` but not @@ -239,16 +270,13 @@ async def _ws_endpoint(self, websocket: WebSocket) -> None: handler_exc = exc raise finally: + if context_token is not None: + reset_request_context(context_token) # Always finalize — emits the close-event log line and # best-effort closes the socket — even when the handler # raised a ``BaseException`` like ``CancelledError``. - error_code: Optional[str] - if handler_exc is None: - error_code = None - elif isinstance(handler_exc, Exception): - error_code = "internal_error" - else: - error_code = "cancelled" + if error_code is None and handler_exc is not None: + error_code = "internal_error" if isinstance(handler_exc, Exception) else "cancelled" await self._finalize_session( websocket=websocket, session_id=session_id, @@ -258,7 +286,9 @@ async def _ws_endpoint(self, websocket: WebSocket) -> None: ) async def _invoke_user_handler( - self, websocket: WebSocket, session_id: str, + self, + websocket: WebSocket, + session_id: str, ) -> tuple[int, Optional[BaseException]]: """Run the registered user handler and classify the outcome. @@ -291,7 +321,9 @@ async def _invoke_user_handler( except Exception as exc: # pylint: disable=broad-exception-caught logger.error( "WebSocket handler raised for session %s: %s", - session_id, exc, exc_info=True, + session_id, + exc, + exc_info=True, ) return InvocationsWSConstants.CLOSE_INTERNAL_ERROR, exc @@ -326,21 +358,16 @@ async def _finalize_session( # application hasn't already done so (e.g. the user handler # may have called ``websocket.close`` itself, or the client # may have disconnected). - if ( - websocket is not None - and websocket.application_state != WebSocketState.DISCONNECTED - ): - reason = ( - "Internal server error" - if close_code == InvocationsWSConstants.CLOSE_INTERNAL_ERROR - else "" - ) + if websocket is not None and websocket.application_state != WebSocketState.DISCONNECTED: + reason = "Internal server error" if close_code == InvocationsWSConstants.CLOSE_INTERNAL_ERROR else "" try: await websocket.close(code=close_code, reason=reason) except Exception: # pylint: disable=broad-exception-caught # Connection already gone — nothing to recover here. logger.debug( - "Error closing WebSocket session %s", session_id, exc_info=True, + "Error closing WebSocket session %s", + session_id, + exc_info=True, ) self._emit_close_event( diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py index 61ff881fcab0..fa13f7486ecb 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "1.2.0" +VERSION = "1.3.0" diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/__init__.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/__init__.py index b1ec4b3184e3..7937723e3c4e 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/__init__.py @@ -1,14 +1,14 @@ # --------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -"""Typed Voice Live Bridge event relay over ``invocations_ws``.""" +"""Typed Voice Live Bridge event relay over ``voice_conversation_engine`` or legacy ``invocations_ws``.""" from ._models import ( AgentError, - BargeIn, EndCall, EndCallMode, InboundVoiceMessage, + InputDataPart, InputTextPart, OutboundVoiceMessage, ResponseAccepted, @@ -18,6 +18,7 @@ ResponseDone, ResponseDropped, ResponseNone, + ResponseOutputDataDone, ResponseOutputTextDelta, ResponseOutputTextDone, ResponseTimeout, @@ -27,6 +28,7 @@ SessionReady, SessionRejected, SessionStart, + UserBargeIn, UserMessage, UserNoInput, UserSpeechStarted, @@ -37,7 +39,6 @@ from ._session import Session, SessionTermination from ._turn import TargetTurn, TargetTurnOrigin, TargetTurnOutcome from ._voice_host import ( - BargeInCallback, ConnectionTerminatingCallback, DisconnectCallback, ResponseAcceptedCallback, @@ -46,6 +47,7 @@ ResponseTimeoutCallback, SessionEndCallback, SessionStartCallback, + UserBargeInCallback, UserMessageCallback, UserNoInputCallback, UserSpeechStartedCallback, @@ -54,13 +56,12 @@ __all__ = [ "AgentError", - "BargeIn", - "BargeInCallback", "ConnectionTerminatingCallback", "DisconnectCallback", "EndCall", "EndCallMode", "InboundVoiceMessage", + "InputDataPart", "InputTextPart", "OutboundVoiceMessage", "ResponseAccepted", @@ -73,6 +74,7 @@ "ResponseDropped", "ResponseDroppedCallback", "ResponseNone", + "ResponseOutputDataDone", "ResponseOutputTextDelta", "ResponseOutputTextDone", "ResponseTimeout", @@ -90,6 +92,8 @@ "TargetTurn", "TargetTurnOrigin", "TargetTurnOutcome", + "UserBargeIn", + "UserBargeInCallback", "UserMessage", "UserMessageCallback", "UserNoInput", diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_codec.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_codec.py index 067e54f17e36..ff39090031ba 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_codec.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_codec.py @@ -17,10 +17,10 @@ from ._models import ( AgentError, - BargeIn, EndCall, EndCallMode, InboundVoiceMessage, + InputDataPart, InputTextPart, OutboundVoiceMessage, ResponseAccepted, @@ -30,6 +30,7 @@ ResponseDone, ResponseDropped, ResponseNone, + ResponseOutputDataDone, ResponseOutputTextDelta, ResponseOutputTextDone, ResponseTimeout, @@ -38,6 +39,7 @@ SessionReady, SessionRejected, SessionStart, + UserBargeIn, UserMessage, UserNoInput, UserSpeechStarted, @@ -134,6 +136,7 @@ "response.created", "response.done", "response.none", + "response.output_data.done", "response.output_text.delta", "response.output_text.done", "session.ready", @@ -197,8 +200,8 @@ def decode_inbound_message(frame: str) -> InboundVoiceMessage | None: # pylint: ) if message_type == "user.speech_started": return UserSpeechStarted(**common) - if message_type == "barge_in": - return BargeIn( + if message_type == "user.barge_in": + return UserBargeIn( **common, response_id=_prefixed_identifier(payload, "response_id", "r_"), item_id=_optional_prefixed_identifier(payload, "item_id", "it_"), @@ -249,6 +252,7 @@ def encode_outbound_message(message: OutboundVoiceMessage) -> str: SessionRejected, ResponseCreated, ResponseNone, + ResponseOutputDataDone, ResponseOutputTextDelta, ResponseOutputTextDone, ResponseDone, @@ -271,15 +275,7 @@ def encode_outbound_message(message: OutboundVoiceMessage) -> str: ) _put_optional_string(payload, "message", message.message) elif isinstance(message, ResponseCreated): - payload["response_id"] = _validate_prefixed_identifier_value(message.response_id, "response_id", "r_") - if message.in_reply_to is not None: - payload["in_reply_to"] = _validate_input_prefix(message.in_reply_to) - if message.admission_timeout_ms is not None or message.supersede_key is not None: - raise ValueError("reply response.created cannot contain proactive admission controls") - else: - if message.admission_timeout_ms is not None: - payload["admission_timeout_ms"] = _validate_admission_timeout_ms(message.admission_timeout_ms) - _put_optional_string(payload, "supersede_key", message.supersede_key, non_empty=True) + _put_response_created_fields(payload, message) elif isinstance(message, ResponseNone): payload["in_reply_to"] = _validate_input_prefix(message.in_reply_to) _put_optional_string(payload, "reason", message.reason) @@ -297,6 +293,12 @@ def encode_outbound_message(message: OutboundVoiceMessage) -> str: text=_validate_string_value(message.text, "text"), ) _put_optional_voice(payload, message.voice) + elif isinstance(message, ResponseOutputDataDone): + payload.update( + response_id=_validate_prefixed_identifier_value(message.response_id, "response_id", "r_"), + item_id=_validate_prefixed_identifier_value(message.item_id, "item_id", "it_"), + data=_thaw_json(message.data), + ) elif isinstance(message, ResponseDone): payload["response_id"] = _validate_prefixed_identifier_value(message.response_id, "response_id", "r_") elif isinstance(message, ResponseCancel): @@ -335,6 +337,27 @@ def encode_outbound_message(message: OutboundVoiceMessage) -> str: return frame +def _put_response_created_fields(payload: dict[str, Any], message: ResponseCreated) -> None: + """Validate response admission fields without owning response lifecycle state. + + :param payload: Outbound frame fields to populate. + :type payload: dict[str, Any] + :param message: Response creation message to validate. + :type message: ResponseCreated + :return: None. + :rtype: None + """ + payload["response_id"] = _validate_prefixed_identifier_value(message.response_id, "response_id", "r_") + if message.in_reply_to is not None: + payload["in_reply_to"] = _validate_input_prefix(message.in_reply_to) + if message.admission_timeout_ms is not None or message.supersede_key is not None: + raise ValueError("reply response.created cannot contain proactive admission controls") + return + if message.admission_timeout_ms is not None: + payload["admission_timeout_ms"] = _validate_admission_timeout_ms(message.admission_timeout_ms) + _put_optional_string(payload, "supersede_key", message.supersede_key, non_empty=True) + + def _parse_session_start(payload: Mapping[str, Any], common: Mapping[str, str]) -> SessionStart: timeout_payload = _required_mapping(payload, "response_timeouts") reconnect = payload.get("reconnect") @@ -370,13 +393,15 @@ def _parse_user_message(payload: Mapping[str, Any], common: Mapping[str, str]) - raw_content = payload.get("content") if not isinstance(raw_content, list) or not raw_content: raise VoiceProtocolError("user.message content must be a non-empty array") - content: list[InputTextPart] = [] + content: list[InputTextPart | InputDataPart] = [] for raw_part in raw_content: if not isinstance(raw_part, dict): raise VoiceProtocolError("user.message content parts must be objects") part_type = raw_part.get("type") if part_type == "input_text": content.append(InputTextPart(text=_required_string(raw_part, "text"))) + elif part_type == "input_data": + content.append(InputDataPart(data=_required_mapping(raw_part, "data"))) elif part_type == "input_image": raise VoiceUnsupportedMessageError("Image content is not supported by this profile") elif not isinstance(part_type, str): diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_models.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_models.py index 772f77464482..67930bdc7803 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_models.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_models.py @@ -189,6 +189,27 @@ class InputTextPart: __repr__ = _voice_model_repr +@experimental +@dataclass(frozen=True, kw_only=True, repr=False) +class InputDataPart: + """One immutable application JSON object in an ordered user message. + + :ivar data: Application-defined JSON object, copied and deeply frozen. + :vartype data: Mapping[str, Any] + :ivar type: Content discriminator, always ``input_data``. + :vartype type: str + """ + + data: Mapping[str, Any] + type: Literal["input_data"] = field(default="input_data", init=False) + __repr__ = _voice_model_repr + + def __post_init__(self) -> None: + if not isinstance(self.data, Mapping): + raise TypeError("data must be a JSON object") + object.__setattr__(self, "data", _freeze_json(self.data, "data", require_finite_numbers=True)) + + @experimental @dataclass(frozen=True, kw_only=True, repr=False) class SessionDisconnected: @@ -277,7 +298,7 @@ def __init__( @experimental @dataclass(frozen=True, kw_only=True, repr=False) class UserMessage(_InboundMessage): - """Completed user text turn. + """Completed user turn containing ordered text and application data. :ivar id: Wire message identifier. :vartype id: str @@ -285,13 +306,13 @@ class UserMessage(_InboundMessage): :vartype ts: str :ivar item_id: Bridge-allocated input item identifier. :vartype item_id: str - :ivar content: Supported text parts in wire order. - :vartype content: tuple[InputTextPart, ...] + :ivar content: Supported text and data parts in wire order. + :vartype content: tuple[InputTextPart | InputDataPart, ...] """ type: ClassVar[str] = "user.message" item_id: str - content: tuple[InputTextPart, ...] + content: tuple[InputTextPart | InputDataPart, ...] @experimental @@ -330,7 +351,7 @@ class UserSpeechStarted(_InboundMessage): @experimental @dataclass(frozen=True, kw_only=True, repr=False) -class BargeIn(_InboundMessage): +class UserBargeIn(_InboundMessage): """Caller interruption and playback snapshot. :ivar id: Wire message identifier. @@ -345,7 +366,7 @@ class BargeIn(_InboundMessage): :vartype item_id: str or None """ - type: ClassVar[str] = "barge_in" + type: ClassVar[str] = "user.barge_in" response_id: str heard_text: str item_id: str | None = None @@ -456,7 +477,7 @@ class SessionEnd(_InboundMessage): | UserMessage | UserNoInput | UserSpeechStarted - | BargeIn + | UserBargeIn | ResponseAccepted | ResponseDropped | ResponseCancelled @@ -618,6 +639,34 @@ def __post_init__(self) -> None: object.__setattr__(self, "voice", _freeze_json(self.voice, "voice")) +@experimental +@dataclass(frozen=True, kw_only=True, repr=False) +class ResponseOutputDataDone(_OutboundMessage): + """Complete one non-audio application-data output item. + + :ivar id: Generated wire message identifier. + :vartype id: str + :ivar ts: Generated sender timestamp. + :vartype ts: str + :ivar response_id: Owning response identifier. + :vartype response_id: str + :ivar item_id: Output item identifier. + :vartype item_id: str + :ivar data: Application-defined JSON object, copied and deeply frozen. + :vartype data: Mapping[str, Any] + """ + + type: ClassVar[str] = "response.output_data.done" + response_id: str + item_id: str + data: Mapping[str, Any] + + def __post_init__(self) -> None: + if not isinstance(self.data, Mapping): + raise TypeError("data must be a JSON object") + object.__setattr__(self, "data", _freeze_json(self.data, "data", require_finite_numbers=True)) + + @experimental @dataclass(frozen=True, kw_only=True, repr=False) class ResponseDone(_OutboundMessage): @@ -711,13 +760,14 @@ class AgentError(_OutboundMessage): _document_dataclass_defaults( ResponseTimeouts, + InputDataPart, InputTextPart, SessionDisconnected, SessionStart, UserMessage, UserNoInput, UserSpeechStarted, - BargeIn, + UserBargeIn, ResponseAccepted, ResponseDropped, ResponseCancelled, @@ -727,6 +777,7 @@ class AgentError(_OutboundMessage): SessionRejected, ResponseCreated, ResponseNone, + ResponseOutputDataDone, ResponseOutputTextDelta, ResponseOutputTextDone, ResponseDone, @@ -741,6 +792,7 @@ class AgentError(_OutboundMessage): | SessionRejected | ResponseCreated | ResponseNone + | ResponseOutputDataDone | ResponseOutputTextDelta | ResponseOutputTextDone | ResponseDone diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_voice_host.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_voice_host.py index b242481eba8f..24912bc386c2 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_voice_host.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/voice/_voice_host.py @@ -1,7 +1,7 @@ # --------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -"""Typed event relay over the existing Invocations WebSocket transport.""" +"""Typed event relay over standalone CE or legacy Invocations WebSocket transport.""" from __future__ import annotations @@ -23,29 +23,32 @@ ) from opentelemetry.propagators.textmap import Getter from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from starlette.middleware import Middleware from starlette.routing import Host, Match, Mount, Router, WebSocketRoute from starlette.types import Receive, Scope, Send from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState from azure.ai.agentserver.core import ( FoundryAgentRequestContext, + FoundryOriginalSubject, configure_observability as _CORE_CONFIGURE_OBSERVABILITY, experimental, set_request_context, ) +from azure.ai.agentserver.core._middleware import _task_access_middleware from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module FOUNDRY_CALL_ID, REQUEST_ID, SERVER_VERSION, USER_ID, ) +from azure.ai.agentserver.core.tasks import resilient_tasks_enabled from .._constants import InvocationsWSConstants, _classify_websocket_close_code from .._invocation import InvocationAgentServerHost from . import _session as _session_transport from ._codec import MAX_FRAME_BYTES, VoiceProtocolError, decode_inbound_message from ._models import ( - BargeIn, InboundVoiceMessage, ResponseAccepted, ResponseCancelled, @@ -54,6 +57,7 @@ SessionDisconnected, SessionEnd, SessionStart, + UserBargeIn, UserMessage, UserNoInput, UserSpeechStarted, @@ -71,7 +75,7 @@ UserMessageCallback = Callable[[Session, UserMessage], Awaitable[None]] UserNoInputCallback = Callable[[Session, UserNoInput], Awaitable[None]] UserSpeechStartedCallback = Callable[[Session, UserSpeechStarted], Awaitable[None]] -BargeInCallback = Callable[[Session, BargeIn], Awaitable[None]] +UserBargeInCallback = Callable[[Session, UserBargeIn], Awaitable[None]] ResponseAcceptedCallback = Callable[[Session, ResponseAccepted], Awaitable[None]] ResponseDroppedCallback = Callable[[Session, ResponseDropped], Awaitable[None]] ResponseCancelledCallback = Callable[[Session, ResponseCancelled], Awaitable[None]] @@ -129,7 +133,7 @@ def _new_voice_context() -> Any: _VOICE_TERMINATION_DEADLINE = "azure.ai.agentserver.invocations.voice.termination_deadline" _VOICE_CONNECTION_TRACE = "azure.ai.agentserver.invocations.voice.connection_trace" _VOICE_CONNECTION_CONTEXT = "azure.ai.agentserver.invocations.voice.connection_context" -_VOICE_ROUTE_CONFLICT = "VoiceAgentServerHost cannot own /invocations_ws because the route is already registered" +_VOICE_ROUTE_CONFLICT = "VoiceAgentServerHost cannot own {path} because the route is already registered" _VOICE_TRACE_PROPAGATOR = _get_voice_trace_propagator() _VOICE_BAGGAGE_KEYS = frozenset( { @@ -184,10 +188,30 @@ def _is_async_callable(callback: Callable[..., Any]) -> bool: class _VoiceWebSocketRoute(WebSocketRoute): - """Reserved Voice route that preserves matching Host and Mount authority.""" + """Reserved Voice route that preserves matching Host and Mount authority. + + :param endpoint: The typed Voice WebSocket endpoint. + :type endpoint: Callable[..., Any] + :keyword router: Router whose Host and Mount authority must be preserved. + :paramtype router: ~starlette.routing.Router + :keyword path: The typed Voice WebSocket route path. + :paramtype path: str + :keyword name: The typed Voice WebSocket route name. + :paramtype name: str + :keyword middleware: Middleware applied to the Voice endpoint. + :paramtype middleware: list[~starlette.middleware.Middleware] | None + """ - def __init__(self, endpoint: Callable[..., Any], *, router: Router) -> None: - super().__init__(InvocationsWSConstants.ROUTE_PATH, endpoint, name="invocations_ws") + def __init__( + self, + endpoint: Callable[..., Any], + *, + router: Router, + path: str = InvocationsWSConstants.ROUTE_PATH, + name: str = "invocations_ws", + middleware: list[Middleware] | None = None, + ) -> None: + super().__init__(path, endpoint, name=name, middleware=middleware) self._router = router def matches(self, scope: Scope) -> tuple[Match, Scope]: @@ -547,16 +571,23 @@ class VoiceAgentServerHost(InvocationAgentServerHost): Agent code owns IDs, application tasks, response lifecycle, terminal-event correlation, cancellation, history, and reconnect restoration. + Both ``/invocations_ws`` and ``/voice_conversation_engine`` expose the same typed + event relay. The legacy route and message handshake version ``1.0`` are + unchanged. Hosted agent protocol declarations are configured separately. + Default Voice observability enables sensitive Agent Framework instrumentation only when ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` is explicitly set to ``true`` or ``1``. Pass a custom ``configure_observability`` callable in ``kwargs`` to use a different policy. - :param openapi_spec: Optional OpenAPI document inherited from Invocations. - :param asyncapi_spec_json: Optional AsyncAPI JSON document. - :param asyncapi_spec_yaml: Optional AsyncAPI YAML document. - :param kwargs: Remaining :class:`InvocationAgentServerHost` options. + :keyword openapi_spec: Optional OpenAPI document inherited from Invocations. + :paramtype openapi_spec: dict[str, Any] or None + :keyword asyncapi_spec_json: Optional AsyncAPI JSON document. + :paramtype asyncapi_spec_json: dict[str, Any] or None + :keyword asyncapi_spec_yaml: Optional AsyncAPI YAML document. + :paramtype asyncapi_spec_yaml: str or None + :keyword dict[str, Any] ``**kwargs``: Remaining :class:`InvocationAgentServerHost` options. """ def __init__( @@ -570,6 +601,7 @@ def __init__( self._voice_callbacks: dict[str, _VoiceCallback] = {} self._connection_terminating_callback: ConnectionTerminatingCallback | None = None self._voice_route: _VoiceWebSocketRoute | None = None + self._conversation_engine_route: _VoiceWebSocketRoute | None = None if "configure_observability" not in kwargs: kwargs["configure_observability"] = _configure_voice_observability super().__init__( @@ -589,19 +621,35 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: def _ensure_ws_route_registered(self) -> None: voice_route = self._voice_route if voice_route is None: - voice_route = _VoiceWebSocketRoute(self._ws_endpoint, router=self.router) + voice_route = _VoiceWebSocketRoute( + self._ws_endpoint, + router=self.router, + middleware=_task_access_middleware(self, resilient_tasks_enabled), + ) self._voice_route = voice_route + ce_route = self._conversation_engine_route + if ce_route is None: + ce_route = _VoiceWebSocketRoute( + self._ws_endpoint, + router=self.router, + path="/voice_conversation_engine", + name="voice_conversation_engine", + middleware=_task_access_middleware(self, resilient_tasks_enabled), + ) + self._conversation_engine_route = ce_route routes = self.router.routes - for route in routes: - if ( - route is not voice_route - and isinstance(route, WebSocketRoute) - and getattr(route, "path", None) == InvocationsWSConstants.ROUTE_PATH - ): - raise RuntimeError(_VOICE_ROUTE_CONFLICT) - if voice_route in routes: - routes.remove(voice_route) - routes.insert(0, voice_route) + for reserved_route in (voice_route, ce_route): + for route in routes: + if ( + route is not reserved_route + and isinstance(route, WebSocketRoute) + and getattr(route, "path", None) == reserved_route.path + ): + raise RuntimeError(_VOICE_ROUTE_CONFLICT.format(path=reserved_route.path)) + for reserved_route in (ce_route, voice_route): + if reserved_route in routes: + routes.remove(reserved_route) + routes.insert(0, reserved_route) async def _ws_endpoint(self, websocket: WebSocket) -> None: session_id = self.config.session_id or str(uuid.uuid4()) @@ -610,6 +658,46 @@ async def _ws_endpoint(self, websocket: WebSocket) -> None: connection_trace = _SpanScope() scope = getattr(websocket, "scope", None) try: + try: + original_subject = FoundryOriginalSubject.from_headers(websocket.headers) + except ValueError: + logger.warning("Voice WebSocket rejected: invalid original-subject platform header.") + error_code = "invalid_original_subject" + try: + # Accept before closing so the peer receives 1011, not an HTTP handshake rejection. + accept_error = await self._accept_voice_websocket(websocket) + error_code = "accept_failed" if accept_error is not None else "invalid_original_subject" + if websocket.application_state == WebSocketState.CONNECTED: + try: + close_attempt = _session_transport._start_close_attempt( + asyncio.Lock(), + websocket, + InvocationsWSConstants.CLOSE_INTERNAL_ERROR, + "Internal server error", + ) + await Session._wait_close(close_attempt, _selected_voice_termination_deadline(websocket)) + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Voice WebSocket close failed") + await _raise_pending_cancellation() + if accept_error is not None: + self._report_voice_accept_failure( + session_id, + start_ns, + emit_event=False, + connection_trace=connection_trace, + ) + except asyncio.CancelledError: + error_code = "cancelled" + raise + finally: + self._emit_voice_close_event( + session_id=session_id, + start_ns=start_ns, + close_code=InvocationsWSConstants.CLOSE_INTERNAL_ERROR, + error_code=error_code, + connection_trace=connection_trace, + ) + return extracted_context = _extract_voice_websocket_context(websocket) parent_attachment = _attach_context(extracted_context) if parent_attachment is not None: @@ -630,6 +718,7 @@ async def _ws_endpoint(self, websocket: WebSocket) -> None: call_id=websocket.headers.get(FOUNDRY_CALL_ID) or None, user_id=websocket.headers.get(USER_ID) or None, session_id=session_id, + original_subject=original_subject, ) ) try: @@ -1089,15 +1178,15 @@ def on_user_speech_started(self, callback: UserSpeechStartedCallback) -> UserSpe """ return self._register_voice_callback(UserSpeechStarted.type, callback) - def on_barge_in(self, callback: BargeInCallback) -> BargeInCallback: - """Register the ``barge_in`` callback. + def on_user_barge_in(self, callback: UserBargeInCallback) -> UserBargeInCallback: + """Register the ``user.barge_in`` callback. :param callback: Async callback receiving the thin Session and event. - :type callback: BargeInCallback + :type callback: UserBargeInCallback :return: The callback, unchanged. - :rtype: BargeInCallback + :rtype: UserBargeInCallback """ - return self._register_voice_callback(BargeIn.type, callback) + return self._register_voice_callback(UserBargeIn.type, callback) def on_response_accepted(self, callback: ResponseAcceptedCallback) -> ResponseAcceptedCallback: """Register the ``response.accepted`` callback. diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml index 32e8de91646f..59b3881669f3 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["azure", "azure sdk", "agent", "agentserver", "invocations"] dependencies = [ - "azure-ai-agentserver-core>=2.1.0,<3.0.0", + "azure-ai-agentserver-core>=2.3.0,<3.0.0", "azure-core>=1.37.0,<2.0.0", "opentelemetry-api>=1.43.0,<2.0.0", # Constraint on the transitive aiohttp: the `--pre` CI install otherwise diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/README.md b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/README.md index c9eb79b4bc06..7223b5072999 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/README.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/README.md @@ -4,6 +4,8 @@ This sample hosts a text-only agent for the Voice Live Bridge protocol `1.0`. The SDK relays typed events while the application owns response generation, task cancellation, correlation, and target-turn trace completion. +Caller interruptions arrive as `user.barge_in` and are handled by `@app.on_user_barge_in` with a `UserBargeIn` event. Voice Live senders must use the same event contract. Interruption cancellation and `heard_text` remain part of the same Bridge message contract. + ## Prerequisites - Python 3.10 or later @@ -11,23 +13,31 @@ task cancellation, correlation, and target-turn trace completion. ## Install -Create and activate a virtual environment, then install the sample dependency: +This sample requires `azure-ai-agentserver-invocations>=1.3.0b1,<2.0.0` for +`UserBargeIn` and `on_user_barge_in`. Version `1.2.x` cannot run this sample. + +Create and activate a virtual environment: ```bash python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate -python -m pip install -r requirements.txt ``` -For local SDK development, install the sibling packages or their wheels instead -of the published dependency: +Until a release containing these APIs and the CE route is published, you must +install a matching local SDK build. Replace the paths below with Core and +Invocations wheels built from the same SDK checkout: ```bash python -m pip install \ - /path/to/azure_ai_agentserver_core-2.1.0b1-py3-none-any.whl \ - /path/to/azure_ai_agentserver_invocations-1.2.0b1-py3-none-any.whl + /path/to/azure_ai_agentserver_core-2.3.0b1-py3-none-any.whl \ + /path/to/azure_ai_agentserver_invocations-1.3.0b1-py3-none-any.whl +python -m pip install -r requirements.txt ``` +After a matching release is available, `python -m pip install -r requirements.txt` +can install the dependency directly from the package feed. The version constraint +alone does not publish the new APIs or make them available in older SDK builds. + ## Run ```bash @@ -35,16 +45,56 @@ python basic_voice_agent.py ``` The server listens on `0.0.0.0` and uses the `PORT` environment variable when -set; otherwise it listens on port `8088`. The hosted platform connects to the -`/invocations_ws` route. +set; otherwise it listens on port `8088`. Foundry selects the container route +from the hosted agent's protocol declaration: `invocations_ws` forwards to +`/invocations_ws`, while `voice_conversation_engine` forwards to `/voice_conversation_engine`. ## Deploy Use the accompanying `agent.manifest.yaml` when configuring the hosted agent. -The three declarations are required together: `invocations_ws`, +For the legacy endpoint, the three declarations are required together: `invocations_ws`, `voiceLiveCompatible: "true"`, and exact `bridgeProtocolVersion: "1.0"`. Omitting the version selects the previously shipped non-Bridge integration. +### Standalone Conversation Engine + +With an SDK build that includes standalone CE support, keep the existing +`app = VoiceAgentServerHost()` construction and declare CE in the hosted agent +definition. Use the field matching the agent's existing schema; enabling CE does +not require a schema migration. + +For new-schema definitions using `container_configuration` or `code_configuration`: + +```yaml +protocol_versions: + - protocol: voice_conversation_engine + version: "1.0.0" +``` + +For legacy definitions using `image`: + +```yaml +container_protocol_versions: + - protocol: voice_conversation_engine + version: "1.0.0" +``` + +These are hosted agent definition fragments, not complete deployment manifests +or replacements for `agent.manifest.yaml`. If the agent must also serve legacy +Bridge calls, retain its existing `invocations_ws` declaration and Bridge metadata. + +The typed host exposes both `/voice_conversation_engine` and `/invocations_ws`, but the +Foundry protocol declaration determines which public endpoint can invoke it. +For CE, no `invocations_ws` declaration, +`voiceLiveCompatible`, or `bridgeProtocolVersion` metadata is required. The existing +callbacks, IDs, cancellation and cleanup code are unchanged, and the message +handshake still uses `protocol_version="1.0"`. The SDK does not modify the +Foundry registration. Existing legacy deployments can keep their manifest and +metadata unchanged. Rebuild the container +with the new SDK before publishing a CE-declaring version. Foundry CE routing and +CE-aware Voice Live must be deployed before enabling this registration; local SDK +tests alone do not prove that the end-to-end route is ready. + The sample intentionally uses a simulated model stream. Replace `generate_answer` with the application's model call while preserving the application-owned task cleanup shown by the event callbacks. diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/basic_voice_agent.py b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/basic_voice_agent.py index d2a4483ae267..2923e4322887 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/basic_voice_agent.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/basic_voice_agent.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from azure.ai.agentserver.invocations.voice import ( - BargeIn, InputTextPart, ResponseCancel, ResponseCancelled, @@ -32,6 +31,7 @@ TargetTurn, TargetTurnOrigin, TargetTurnOutcome, + UserBargeIn, UserMessage, VoiceAgentServerHost, new_item_id, @@ -321,8 +321,8 @@ def on_generation_finished(completed: asyncio.Task[None]) -> None: raise -@app.on_barge_in -async def on_barge_in(session: Session, event: BargeIn) -> None: +@app.on_user_barge_in +async def on_user_barge_in(session: Session, event: UserBargeIn) -> None: """Stop generation and reconcile history from the playback snapshot.""" cancel_generation(session, event.response_id, TargetTurnOutcome.CANCELLED) logger.info("Voice response interrupted") diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/requirements.txt index e0009d4236b2..99a963687c17 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/requirements.txt +++ b/sdk/agentserver/azure-ai-agentserver-invocations/samples/basic_voice_agent/requirements.txt @@ -1 +1 @@ -azure-ai-agentserver-invocations>=1.2.0b1,<2.0.0 \ No newline at end of file +azure-ai-agentserver-invocations>=1.3.0b1,<2.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_invoke.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_invoke.py index 5de15efd63cc..082a2772c649 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_invoke.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_invoke.py @@ -2,10 +2,167 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- """Tests for the POST /invocations invoke dispatch.""" +import asyncio import json import uuid +from importlib.metadata import requires, version +from urllib.parse import unquote import pytest +import httpx +from opentelemetry import context as otel_context +from packaging.requirements import Requirement +from packaging.version import Version +from starlette.requests import Request +from starlette.responses import JSONResponse, StreamingResponse + +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + get_request_context, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from azure.ai.agentserver.invocations import _invocation + + +def test_original_subject_core_dependency_floor(): + requirements = (Requirement(value) for value in requires("azure-ai-agentserver-invocations") or []) + core = next(value for value in requirements if value.name == "azure-ai-agentserver-core") + package_version = Version(version("azure-ai-agentserver-invocations")) + if package_version.pre and package_version.pre[0] == "a": + assert core.specifier.contains(Version(version("azure-ai-agentserver-core")), prereleases=True) + else: + assert "2.3.0" in core.specifier + assert "2.3.0b3" not in core.specifier + assert "2.3.0b2" not in core.specifier + assert "2.3.0b1" not in core.specifier + assert "2.1.0" not in core.specifier + assert callable(FoundryAgentRequestContext.model_attribution_headers) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["invoke", "get", "cancel"]) +@pytest.mark.parametrize("fields", [ + [("x-ms-original-subject", "malformed")], + [("x-ms-original-subject", 'oid="one", oid="two"')], + [("x-ms-original-subject", 'unknown="value"')], + [("x-ms-original-subject", 'oid="one";parameter')], + [("x-ms-original-subject", 'oid="one"'), ("x-ms-original-subject", 'tid="two"')], +]) +async def test_malformed_original_subject_preserves_ambient_context(operation, fields): + app = InvocationAgentServerHost(configure_observability=None) + + async def unexpected_dispatch(_request): + pytest.fail("Malformed attribution must not reach a handler.") + + app.invoke_handler(unexpected_dispatch) + request = Request({ + "type": "http", + "method": "POST" if operation != "get" else "GET", + "path": "/invocations", + "path_params": {"invocation_id": "invocation-test"}, + "query_string": b"", + "headers": [(name.encode(), value.encode()) for name, value in fields], + }) + outer = FoundryAgentRequestContext(call_id="outer-call") + token = set_request_context(outer) + baggage = otel_context.get_current() + invocation_id = _invocation._invocation_id_var.get() + session_id = _invocation._session_id_var.get() + try: + if operation == "invoke": + response = await app._create_invocation_endpoint(request) + else: + response = await app._traced_invocation_endpoint(request, operation, unexpected_dispatch) + assert response.status_code == 400 + assert response.headers["x-platform-error-source"] == "platform" + assert response.headers["x-platform-error-detail"] == "invalid_original_subject" + assert get_request_context() is outer + assert otel_context.get_current() == baggage + assert _invocation._invocation_id_var.get() == invocation_id + assert _invocation._session_id_var.get() == session_id + finally: + reset_request_context(token) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_invoke_original_subject_is_available_during_body_iteration(stream): + app = InvocationAgentServerHost() + + @app.invoke_handler + async def handler(_request): + if stream: + + async def body(): + yield json.dumps(get_request_context().model_attribution_headers()) + + return StreamingResponse(body(), media_type="application/json") + return JSONResponse(get_request_context().model_attribution_headers()) + + subject = { + "x-ms-original-subject": 'oid="original-oid", tid="original-tid", idtype="app"', + } + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + response = await client.post("/invocations", headers={**subject, "x-agent-user-id": "state-key"}) + assert response.status_code == 200 + forwarded = response.json() + assert not any(name.lower().startswith("x-ms-original-subject") for name in forwarded) + assert unquote(forwarded["x-ms-foundry-baggage"].split("=", 1)[1]) == subject["x-ms-original-subject"] + assert len(forwarded) == 1 + response = await client.post("/invocations") + assert response.status_code == 200 + assert response.json() == {} + assert get_request_context().original_subject is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_concurrent_invocations_keep_baggage_bound_to_each_body(stream): + app = InvocationAgentServerHost(configure_observability=None) + both_bound = asyncio.Event() + bound = 0 + + async def current_headers(): + nonlocal bound + bound += 1 + if bound == 2: + both_bound.set() + await asyncio.wait_for(both_bound.wait(), timeout=10) + return get_request_context().model_attribution_headers({"x-ms-foundry-baggage": "application=shared"}) + + @app.invoke_handler + async def handler(_request): + if stream: + + async def body(): + yield json.dumps(await current_headers()) + + return StreamingResponse(body(), media_type="application/json") + return JSONResponse(await current_headers()) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + + async def invoke(oid): + subject = f'oid="{oid}", tid="tenant-{oid}", idtype="user"' + response = await client.post( + "/invocations", + headers={ + "x-ms-original-subject": subject, + "x-agent-foundry-call-id": f"call-{oid}", + }, + ) + assert response.status_code == 200 + forwarded = response.json() + assert not any(name.lower().startswith("x-ms-original-subject") for name in forwarded) + assert forwarded["x-agent-foundry-call-id"] == f"call-{oid}" + members = forwarded["x-ms-foundry-baggage"].split(",") + assert members[1] == "application=shared" + assert unquote(members[0].split("=", 1)[1]) == subject + + await asyncio.gather(invoke("alice"), invoke("bob")) + assert get_request_context().original_subject is None # --------------------------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_protocol_task_enablement.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_protocol_task_enablement.py new file mode 100644 index 000000000000..aa42c48a632a --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_protocol_task_enablement.py @@ -0,0 +1,328 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Invocations enablement must remain independent of Responses features.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest +from starlette.responses import JSONResponse +from starlette.testclient import TestClient + +from azure.ai.agentserver.core.tasks import ( + TaskContext, + TaskManagerNotInitialized, + resilient_tasks_enabled, + set_resilient_tasks_enabled, + task, +) +from azure.ai.agentserver.core.tasks._manager import get_task_manager, set_task_manager +from azure.ai.agentserver.invocations import InvocationAgentServerHost + + +@pytest.fixture(autouse=True) +def isolated_tasks(monkeypatch: pytest.MonkeyPatch, tmp_path): + from azure.ai.agentserver.core.tasks import _manager + + previous_flag = resilient_tasks_enabled() + previous_manager = _manager._manager + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local") + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path)) + set_resilient_tasks_enabled(False) + set_task_manager(None) + try: + yield + finally: + set_resilient_tasks_enabled(previous_flag) + set_task_manager(previous_manager) + + +def manager_available() -> bool: + try: + get_task_manager() + return True + except TaskManagerNotInitialized: + return False + + +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +@pytest.mark.parametrize("responses_first", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("enabled", [False, True]) +def test_composed_voice_routes_mask_disabled_invocations_tasks( + path: str, responses_first: bool, resilient: bool, steerable: bool, enabled: bool +) -> None: + responses = pytest.importorskip("azure.ai.agentserver.responses") + from azure.ai.agentserver.invocations.voice import SessionReady, VoiceAgentServerHost + + response_host = responses.ResponsesAgentServerHost + if responses_first: + + class CombinedHost(response_host, VoiceAgentServerHost): + pass + + else: + + class CombinedHost(VoiceAgentServerHost, response_host): + pass + + set_resilient_tasks_enabled(enabled) + host = CombinedHost( + options=responses.ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + voice_seen = [] + responses_seen = [] + executed = [] + + @task(name="voice_protocol_probe") + async def probe(ctx: TaskContext[str]) -> str: + executed.append(ctx.input) + return ctx.input + + @host.on_session_start + async def start(session, event): + voice_seen.append(manager_available()) + if manager_available(): + run = await probe.start(task_id="voice-probe", input="ran") + assert await run.result() == "ran" + else: + with pytest.raises(TaskManagerNotInitialized): + await probe.start(task_id="voice-probe", input="must not run") + await session.send(SessionReady()) + + @host.response_handler + async def response_handler(request, context, cancellation_signal): + responses_seen.append(manager_available()) + events = responses.ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + with TestClient(host) as client: + with client.websocket_connect(path) as websocket: + websocket.send_json( + { + "type": "session.start", + "id": "m_start", + "ts": "2026-10-15T00:00:00Z", + "protocol_version": "1.0", + "reconnect": False, + "response_timeouts": {"first_output_ms": 1000, "idle_ms": 1000, "max_duration_ms": 3000}, + } + ) + assert websocket.receive_json()["type"] == "session.ready" + assert voice_seen == [enabled] + assert executed == (["ran"] if enabled else []) + response = client.post("/responses", json={"input": "hello", "store": False}) + assert response.status_code == 200, response.text + assert responses_seen == [resilient or steerable] + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_invocations_switch_controls_http_and_websocket(enabled: bool) -> None: + set_resilient_tasks_enabled(enabled) + host = InvocationAgentServerHost(configure_observability=None) + + @host.invoke_handler + async def invoke(request): + return JSONResponse({"task_runtime": manager_available()}) + + @host.ws_handler + async def websocket_handler(websocket): + await websocket.send_json({"task_runtime": manager_available()}) + + with TestClient(host) as client: + assert client.post("/invocations", json={}).json() == {"task_runtime": enabled} + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_json() == {"task_runtime": enabled} + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("responses_first", [False, True]) +def test_composed_protocols_use_independent_enablement( + enabled: bool, resilient: bool, steerable: bool, responses_first: bool +) -> None: + responses = pytest.importorskip("azure.ai.agentserver.responses") + response_host = responses.ResponsesAgentServerHost + if responses_first: + + class CombinedHost(response_host, InvocationAgentServerHost): + pass + + else: + + class CombinedHost(InvocationAgentServerHost, response_host): + pass + + set_resilient_tasks_enabled(enabled) + host = CombinedHost( + options=responses.ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + assert resilient_tasks_enabled() is enabled + observed: list[bool] = [] + + @host.invoke_handler + async def invoke(request): + return JSONResponse({"task_runtime": manager_available()}) + + @host.response_handler + async def response_handler(request, context, cancellation_signal): + observed.append(context._task_context is not None) + assert manager_available() is (resilient or steerable) + events = responses.ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + with TestClient(host) as client: + assert (host._task_manager is not None) is (enabled or resilient or steerable) + assert client.post("/invocations", json={}).json() == {"task_runtime": enabled} + result = client.post("/responses", json={"input": "hello", "store": True}) + assert result.status_code == 200, result.text + assert observed == [resilient or steerable] + assert client.post("/invocations", json={}).json() == {"task_runtime": enabled} + + +@pytest.mark.parametrize("responses_first", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("multi_turn", [False, True]) +@pytest.mark.parametrize("separate_hosts", [False, True]) +@pytest.mark.asyncio +async def test_protocol_recovery_requires_enabled_owning_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path, + responses_first: bool, + resilient: bool, + steerable: bool, + multi_turn: bool, + separate_hosts: bool, +) -> None: + responses = pytest.importorskip("azure.ai.agentserver.responses") + from azure.ai.agentserver.core.tasks._lease import derive_lease_owner + from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider + from azure.ai.agentserver.core.tasks._manager import TaskManager, _SCHEMA_VERSION, _SCHEMA_VERSION_KEY + from azure.ai.agentserver.core.tasks._models import TaskCreateRequest + from azure.ai.agentserver.responses import PlatformContext + from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + from azure.ai.agentserver.responses.models._generated import CreateResponse, ResponseObject + from azure.ai.agentserver.responses.store._file import FileResponseStore + + response_host = responses.ResponsesAgentServerHost + if responses_first: + + class CombinedHost(response_host, InvocationAgentServerHost): + pass + + else: + + class CombinedHost(InvocationAgentServerHost, response_host): + pass + + task_store = LocalFileTaskProvider(tmp_path / "tasks") + store = FileResponseStore(storage_dir=tmp_path / "responses") + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(lambda _: task_store)) + set_resilient_tasks_enabled(True) + host_type = response_host if separate_hosts else CombinedHost + host = host_type( + options=responses.ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + store=store, + configure_observability=None, + ) + active_host = InvocationAgentServerHost(configure_observability=None) if separate_hosts else host + invocation_ran = asyncio.Event() + observed = [] + + @task(name="invocations_recovery_control") + async def invocation_work(ctx: TaskContext[str]) -> str: + assert ctx.entry_mode == "recovered" + assert get_task_manager() is active_host._task_manager + invocation_ran.set() + return "invocations still recover" + + @host.response_handler + async def handler(request, context, cancellation_signal): + observed.append((context.is_recovery, get_task_manager() is active_host._task_manager)) + events = responses.ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + response_id = "resp_disabled_protocol_recovery" + platform_context = PlatformContext(user_id_key="owner") + await store.create_response( + ResponseObject( + { + "id": response_id, + "object": "response", + "status": "in_progress", + "background": True, + "model": "m", + "output": [], + } + ), + [], + None, + context=platform_context, + ) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + primitive = orchestrator._multi_turn_task_fn if multi_turn else orchestrator._one_shot_task_fn + params = ResilientResponseInput( + request=CreateResponse(model="m", input="old work", store=True, background=True, stream=False), + response_id=response_id, + disposition="re-invoke", + user_id_key="owner", + ).to_task_input() + response_task_id = derive_lifecycle_id(response_id, "owner") + for name, task_id, task_input in ( + (primitive.name, response_task_id, params), + (invocation_work.name, "invocations-recovery-control", "continue"), + ): + payload = {"input": task_input, _SCHEMA_VERSION_KEY: _SCHEMA_VERSION} + if multi_turn and name == primitive.name: + payload["last_input_id"] = derive_lifecycle_id(response_id, "owner") + info = await task_store.create( + TaskCreateRequest( + id=task_id, + agent_name=host.config.agent_name or "default", + session_id=host.config.session_id or "local", + status="in_progress", + title=name, + payload=payload, + tags={"task_name": name}, + source=TaskManager._build_source(name), + lease_owner=derive_lease_owner( + host.config.agent_name or "unknown-agent", host.config.session_id or "local" + ), + lease_instance_id="interrupted-instance", + lease_duration_seconds=60, + ) + ) + info.lease.expires_at = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() + task_store._write_task(info) + before = await task_store.get(response_task_id) + async with active_host.router.lifespan_context(active_host): + await asyncio.wait_for(invocation_ran.wait(), timeout=5) + if (resilient or steerable) and not separate_hosts: + + async def wait_for_terminal(): + while (await store.get_response(response_id, context=platform_context))["status"] == "in_progress": + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_terminal(), timeout=5) + assert observed == [(True, True)] + assert (await store.get_response(response_id, context=platform_context))["status"] == "completed" + else: + assert observed == [] + if separate_hosts: + assert host._task_manager is None + after = await task_store.get(response_task_id) + assert after.status == before.status == "in_progress" + assert after.etag == before.etag + assert after.lease.instance_id == before.lease.instance_id == "interrupted-instance" + assert after.lease.generation == before.lease.generation + assert after.payload == before.payload + assert (await store.get_response(response_id, context=platform_context))["status"] == "in_progress" diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_recovery_restart_identity.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_recovery_restart_identity.py new file mode 100644 index 000000000000..8d61555cfeaf --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_recovery_restart_identity.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Persisted function identity must survive a fresh Invocations-only process.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest + + +_RESTART_PROBE = textwrap.dedent( + """ + import asyncio + from datetime import datetime, timedelta, timezone + import json + import os + from pathlib import Path + from unittest.mock import patch + + from azure.ai.agentserver.core.tasks import TaskContext, set_resilient_tasks_enabled, task + from azure.ai.agentserver.core.tasks._decorator import _HOST_BOUND_TASK_NAMES, _REGISTERED_DESCRIPTORS + from azure.ai.agentserver.core.tasks._lease import derive_lease_owner + from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider + from azure.ai.agentserver.core.tasks._manager import TaskManager, _SCHEMA_VERSION, _SCHEMA_VERSION_KEY + from azure.ai.agentserver.core.tasks._models import TaskCreateRequest + from azure.ai.agentserver.invocations import InvocationAgentServerHost + + assert not _HOST_BOUND_TASK_NAMES + assert not _REGISTERED_DESCRIPTORS + seen = [] + + @task(name="ordinary_invocations_work") + async def work(ctx: TaskContext[dict]) -> None: + seen.append(ctx.task_id) + + async def main(): + store = LocalFileTaskProvider(Path(os.environ["AGENTSERVER_STATE_ROOT"]) / "tasks") + set_resilient_tasks_enabled(True) + with patch.object(TaskManager, "_create_provider", staticmethod(lambda _: store)): + host = InvocationAgentServerHost(configure_observability=None) + for task_id, name in ( + ("known", work.name), + ("responses", "responses_resilient_one_shot"), + ("legacy", None), + ): + payload = {"input": {"original_task": task_id}, _SCHEMA_VERSION_KEY: _SCHEMA_VERSION} + if task_id == "responses" and os.environ["PROBE_PRE_SCHEMA"] == "true": + payload.pop(_SCHEMA_VERSION_KEY) + source = TaskManager._build_source(name) if name else {"type": "agentserver.task"} + info = await store.create( + TaskCreateRequest( + id=task_id, + agent_name=host.config.agent_name or "default", + session_id=host.config.session_id or "local", + status="in_progress", + title="unrelated title", + payload=payload, + source=source, + lease_owner=derive_lease_owner( + host.config.agent_name or "unknown-agent", host.config.session_id or "local" + ), + lease_instance_id="interrupted-instance", + lease_duration_seconds=60, + ) + ) + info.lease.expires_at = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() + store._write_task(info) + before = await store.get("responses") + async with host.router.lifespan_context(host): + executions = [ + active.execution_task + for active in host._task_manager._active_tasks.values() + if active.execution_task is not None + ] + await asyncio.wait_for(asyncio.gather(*executions), timeout=5) + after = await store.get("responses") + print(json.dumps({"seen": seen, "responses_record_exists": after is not None})) + assert set(seen) == {"known", "legacy"}, seen + assert after is not None + assert after.status == before.status == "in_progress" + assert after.etag == before.etag + assert after.lease.instance_id == before.lease.instance_id == "interrupted-instance" + assert after.lease.generation == before.lease.generation + assert after.payload == before.payload + + asyncio.run(main()) + """ +) + + +@pytest.mark.parametrize("pre_schema", [False, True]) +def test_restart_preserves_unregistered_responses_identity(tmp_path: Path, pre_schema: bool) -> None: + env = {key: value for key, value in os.environ.items() if not key.startswith("FOUNDRY_")} + env.update( + AGENTSERVER_TASKS_BACKEND="local", + AGENTSERVER_STATE_ROOT=str(tmp_path), + PROBE_PRE_SCHEMA=str(pre_schema).lower(), + ) + result = subprocess.run( + [sys.executable, "-c", _RESTART_PROBE], + cwd=Path(__file__).resolve().parents[1], + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + assert result.returncode == 0, f"{result.stdout}\n{result.stderr}" + evidence = json.loads(result.stdout.splitlines()[-1]) + assert set(evidence["seen"]) == {"known", "legacy"} + assert evidence["responses_record_exists"] diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_ws_invoke.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_ws_invoke.py index 7fa992566d76..6e9d8589e7fb 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_ws_invoke.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/test_ws_invoke.py @@ -8,17 +8,89 @@ WebSocket-only bidirectional streaming property. """ import asyncio +import logging +from urllib.parse import unquote import pytest from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect from azure.ai.agentserver.invocations import InvocationAgentServerHost +from azure.ai.agentserver.core import get_request_context from azure.ai.agentserver.invocations._constants import InvocationsWSConstants from conftest import _make_echo_ws_app, _make_failing_ws_app +@pytest.mark.parametrize("value", ["malformed", 'oid="one", oid="two"', 'unknown="value"']) +def test_ws_malformed_original_subject_closes_and_finalizes(value, caplog): + app = InvocationAgentServerHost(configure_observability=None) + handled = [] + + @app.ws_handler + async def handler(websocket: WebSocket) -> None: + handled.append(True) + await websocket.send_json(get_request_context().model_attribution_headers()) + + client = TestClient(app) + with caplog.at_level(logging.INFO, logger="azure.ai.agentserver"): + with pytest.raises(WebSocketDisconnect) as error: + with client.websocket_connect("/invocations_ws", headers={"x-ms-original-subject": value}) as websocket: + websocket.receive_json() + assert error.value.code == 1011 + assert not handled + assert any( + getattr(record, InvocationsWSConstants.ATTR_SPAN_ERROR_CODE, None) == "invalid_original_subject" + for record in caplog.records + ) + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_json() == {} + assert get_request_context().original_subject is None + + +def test_ws_original_subject_is_invocation_local(): + app = InvocationAgentServerHost() + + @app.ws_handler + async def handler(websocket: WebSocket) -> None: + await websocket.send_json(get_request_context().model_attribution_headers()) + + subject = { + "x-ms-original-subject": 'oid="original-oid", tid="original-tid", idtype="user"', + } + client = TestClient(app) + with client.websocket_connect("/invocations_ws", headers=dict(subject)) as websocket: + forwarded = websocket.receive_json() + assert not any(name.lower().startswith("x-ms-original-subject") for name in forwarded) + assert unquote(forwarded["x-ms-foundry-baggage"].split("=", 1)[1]) == subject["x-ms-original-subject"] + assert len(forwarded) == 1 + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_json() == {} + assert get_request_context().original_subject is None + + +def test_overlapping_websockets_keep_subject_baggage_isolated(): + app = InvocationAgentServerHost(configure_observability=None) + + @app.ws_handler + async def handler(websocket: WebSocket) -> None: + await websocket.receive_text() + await websocket.send_json(get_request_context().model_attribution_headers()) + + alice = 'oid="alice", tid="tenant-alice", idtype="user"' + bob = 'oid="bob", tid="tenant-bob", idtype="app"' + with TestClient(app) as client: + with client.websocket_connect("/invocations_ws", headers={"x-ms-original-subject": alice}) as first: + with client.websocket_connect("/invocations_ws", headers={"x-ms-original-subject": bob}) as second: + first.send_text("ready") + second.send_text("ready") + for websocket, subject in ((first, alice), (second, bob)): + forwarded = websocket.receive_json() + assert not any(name.lower().startswith("x-ms-original-subject") for name in forwarded) + assert unquote(forwarded["x-ms-foundry-baggage"].split("=", 1)[1]) == subject + assert get_request_context().original_subject is None + + # --------------------------------------------------------------------------- # Accept happens automatically # --------------------------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_basic_voice_agent.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_basic_voice_agent.py index 2fe2c527530c..a619a42159aa 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_basic_voice_agent.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_basic_voice_agent.py @@ -10,6 +10,7 @@ import pytest import yaml +from packaging.requirements import Requirement import azure.ai.agentserver.invocations.voice as voice from azure.ai.agentserver.invocations.voice import ( @@ -27,6 +28,7 @@ SessionStart, SessionTermination, TargetTurnOutcome, + UserBargeIn, UserMessage, ) @@ -185,7 +187,8 @@ def test_sample_includes_setup_run_and_bridge_manifest(): assert "python -m pip install -r requirements.txt" in readme assert "python basic_voice_agent.py" in readme assert "/invocations_ws" in readme - assert requirements == ["azure-ai-agentserver-invocations>=1.2.0b1,<2.0.0"] + assert requirements == ["azure-ai-agentserver-invocations>=1.3.0b1,<2.0.0"] + assert "azure_ai_agentserver_invocations-1.3.0b1-py3-none-any.whl" in readme manifest = yaml.safe_load((_SAMPLE_ROOT / "agent.manifest.yaml").read_text(encoding="utf-8")) assert manifest["protocols"] == ["invocations_ws"] @@ -193,6 +196,16 @@ def test_sample_includes_setup_run_and_bridge_manifest(): assert manifest["bridgeProtocolVersion"] == "1.0" +@pytest.mark.parametrize( + ("version", "supported"), + [("1.2.0b1", False), ("1.2.0", False), ("1.3.0b1", True), ("1.3.0", True), ("2.0.0", False)], +) +def test_sample_dependency_requires_user_barge_in_sdk(version, supported): + requirement = Requirement((_SAMPLE_ROOT / "requirements.txt").read_text(encoding="utf-8").strip()) + assert requirement.name == "azure-ai-agentserver-invocations" + assert requirement.specifier.contains(version) is supported + + @pytest.mark.asyncio async def test_session_start_accepts_only_the_supported_protocol(sample_module): unsupported_session = _CapturingSession() @@ -256,6 +269,35 @@ async def blocked_generation(_text): assert not sample_module.input_generations +@pytest.mark.asyncio +async def test_user_barge_in_cancels_sample_owned_generation(sample_module, monkeypatch): + started = asyncio.Event() + + async def blocked_generation(_text): + started.set() + await asyncio.Event().wait() + yield "not reached" + + monkeypatch.setattr(sample_module, "generate_answer", blocked_generation) + session = _CapturingSession() + await sample_module.on_user_message( + session, + UserMessage(id="m_user", ts="2026-10-19T00:00:00Z", item_id="in_1", content=(InputTextPart(text="hello"),)), + ) + await asyncio.wait_for(started.wait(), timeout=1) + generation = next(iter(sample_module.generations.values())) + await sample_module.on_user_barge_in( + session, + UserBargeIn(id="m_interrupt", ts="2026-10-19T00:00:01Z", response_id=generation.response_id, heard_text=""), + ) + with pytest.raises(asyncio.CancelledError): + await generation.task + assert generation.task.cancelled() + assert generation.turn.completions[0]["outcome"] == TargetTurnOutcome.CANCELLED + assert not sample_module.generations + assert not sample_module.input_generations + + @pytest.mark.asyncio @pytest.mark.parametrize( ("termination", "expected"), diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_codec.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_codec.py index 257c95075080..15cb10f4b48e 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_codec.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_codec.py @@ -13,7 +13,6 @@ import azure.ai.agentserver.invocations.voice as voice from azure.ai.agentserver.invocations.voice import ( AgentError, - BargeIn, EndCall, InputTextPart, ResponseCancel, @@ -29,6 +28,7 @@ SessionReady, SessionRejected, SessionStart, + UserBargeIn, UserMessage, ) from azure.ai.agentserver.invocations.voice._codec import ( @@ -51,6 +51,57 @@ def _frame(message_type: str, **fields): ) +class TestDataPassthrough: + def test_decode_preserves_mixed_content_and_freezes_data(self) -> None: + event = decode_inbound_message( + _frame( + "user.message", + item_id="in_data", + content=[ + {"type": "input_text", "text": "hello"}, + {"type": "input_data", "data": {"nested": [1, True, None, {"key": "value"}]}}, + ], + ) + ) + assert isinstance(event, UserMessage) + assert len(event.content) == 2 + assert event.content[1].data["nested"] == (1, True, None, {"key": "value"}) + with pytest.raises(TypeError): + event.content[1].data["nested"][3]["key"] = "changed" + + @pytest.mark.parametrize("payload", [None, [], "text", 1]) + def test_decode_rejects_non_object_data(self, payload: object) -> None: + with pytest.raises(VoiceProtocolError): + decode_inbound_message( + _frame( + "user.message", + item_id="in_data", + content=[{"type": "input_data", "data": payload}], + ) + ) + + def test_encode_data_item_copies_payload_and_redacts_repr(self) -> None: + payload = {"type": "hubagent.metadata", "data": {"secret": ["private-value"]}} + message = voice.ResponseOutputDataDone(response_id="r_data", item_id="it_data", data=payload) + payload["data"]["secret"].append("mutated") + encoded = json.loads(encode_outbound_message(message)) + assert encoded["type"] == "response.output_data.done" + assert encoded["data"]["data"]["secret"] == ["private-value"] + assert "private-value" not in repr(message) + + def test_empty_data_object_is_valid(self) -> None: + event = decode_inbound_message( + _frame( + "user.message", + item_id="in_data", + content=[{"type": "input_data", "data": {}}], + ) + ) + assert event.content[0].data == {} + message = voice.ResponseOutputDataDone(response_id="r_data", item_id="it_data", data={}) + assert json.loads(encode_outbound_message(message))["data"] == {} + + @pytest.mark.parametrize( "frame", [ @@ -63,7 +114,7 @@ def _frame(message_type: str, **fields): _frame("user.message", item_id="in_1", content=[{"type": "input_text", "text": "hello"}]), _frame("user.no_input", item_id="in_2", count=1), _frame("user.speech_started"), - _frame("barge_in", response_id="r_1", heard_text="heard"), + _frame("user.barge_in", response_id="r_1", heard_text="heard"), _frame("response.accepted", response_id="r_2"), _frame("response.dropped", response_id="r_3", reason="queue_full"), _frame("response.cancelled", response_id="r_4", heard_text="heard"), @@ -78,6 +129,26 @@ def test_decode_all_selected_inbound_messages(frame): assert event.id == "m_1" +def test_user_barge_in_decodes_with_playback_snapshot(): + event = decode_inbound_message(_frame("user.barge_in", response_id="r_1", item_id="it_1", heard_text="heard")) + assert event is not None + assert isinstance(event, UserBargeIn) + assert event.type == "user.barge_in" + assert event.response_id == "r_1" + assert event.item_id == "it_1" + assert event.heard_text == "heard" + + +def test_legacy_barge_in_is_not_a_supported_event(): + assert decode_inbound_message(_frame("barge_in", response_id="r_1", heard_text="heard")) is None + + +def test_old_barge_in_public_names_are_not_retained(): + assert not hasattr(voice, "BargeIn") + assert not hasattr(voice, "BargeInCallback") + assert not hasattr(voice.VoiceAgentServerHost, "on_barge_in") + + def test_session_start_caller_is_deeply_read_only(): event = decode_inbound_message( _frame( @@ -338,13 +409,14 @@ def test_session_start_reconnect_rejects_present_greeting(): ) -def test_selected_registry_is_exactly_ten_each_direction(): +def test_selected_registry_includes_data_output() -> None: inbound = InboundVoiceMessage.__args__ outbound = OutboundVoiceMessage.__args__ assert len(inbound) == 10 - assert len(outbound) == 10 + assert len(outbound) == 11 + assert voice.ResponseOutputDataDone in outbound assert len({message.type for message in inbound}) == 10 - assert len({message.type for message in outbound}) == 10 + assert len({message.type for message in outbound}) == 11 def test_all_public_voice_callables_are_experimental(): @@ -388,7 +460,7 @@ def test_voice_model_repr_redacts_sensitive_payloads(): item_id="in_1", content=(InputTextPart(text=sensitive),), ), - BargeIn( + UserBargeIn( id="m_3", ts="2026-08-12T00:00:00Z", response_id="r_1", diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_host.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_host.py index e69de29bb2d1..7d9e4d600144 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_host.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_host.py @@ -0,0 +1,245 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Standalone CE endpoint registration and legacy transport isolation tests.""" + +import re + +import pytest +from starlette.applications import Starlette +from starlette.routing import Host, Mount, WebSocketRoute +from starlette.testclient import TestClient +from starlette.websockets import WebSocket, WebSocketDisconnect + +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from azure.ai.agentserver.invocations.voice import ( + ResponseCreated, + ResponseDone, + ResponseOutputTextDone, + Session, + SessionReady, + SessionStart, + UserMessage, + VoiceAgentServerHost, +) + + +@pytest.fixture(name="session_start_frame") +def _session_start_frame() -> dict[str, object]: + return { + "type": "session.start", + "id": "m_start", + "ts": "2026-10-15T00:00:00Z", + "protocol_version": "1.0", + "reconnect": False, + "response_timeouts": {"first_output_ms": 1000, "idle_ms": 1000, "max_duration_ms": 3000}, + } + + +class TestVoiceEndpointRoutes: + """Keep both endpoints independent of the unchanged typed Bridge wire contract.""" + + def test_host_adds_ce_without_replacing_legacy_route(self) -> None: + app = VoiceAgentServerHost(configure_observability=None) + routes = [(route.path, route.name) for route in app.routes if isinstance(route, WebSocketRoute)] + assert routes == [ + ("/invocations_ws", "invocations_ws"), + ("/voice_conversation_engine", "voice_conversation_engine"), + ] + assert app.url_path_for("invocations_ws") == "/invocations_ws" + assert app.url_path_for("voice_conversation_engine") == "/voice_conversation_engine" + + def test_typed_host_does_not_expose_superseded_ce_route(self) -> None: + app = VoiceAgentServerHost(configure_observability=None) + + with TestClient(app) as client: + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/conversation_engine"): + pytest.fail("The superseded CE route must not be accepted") + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_both_endpoints_relay_existing_bridge_messages(self, protocol: str, session_start_frame) -> None: + app = VoiceAgentServerHost(configure_observability=None) + paths = [route.path for route in app.routes if isinstance(route, WebSocketRoute)] + assert paths == ["/invocations_ws", "/voice_conversation_engine"] + + @app.on_session_start + async def start(session: Session, event: SessionStart) -> None: + assert event.protocol_version == "1.0" + await session.send(SessionReady()) + + @app.on_user_message + async def respond(session: Session, event: UserMessage) -> None: + await session.send(ResponseCreated(response_id="r_reply", in_reply_to=(event.item_id,))) + await session.send(ResponseOutputTextDone(response_id="r_reply", item_id="it_reply", text="Hello.")) + await session.send(ResponseDone(response_id="r_reply")) + + with TestClient(app).websocket_connect(f"/{protocol}") as websocket: + websocket.send_json(session_start_frame) + assert websocket.receive_json()["type"] == "session.ready" + websocket.send_json( + { + "type": "user.message", + "id": "m_input", + "ts": "2026-10-15T00:00:01Z", + "item_id": "in_question", + "content": [{"type": "input_text", "text": "Hello"}], + } + ) + created = websocket.receive_json() + assert created["type"] == "response.created" + assert created["in_reply_to"] == ["in_question"] + assert websocket.receive_json()["text"] == "Hello." + assert websocket.receive_json()["type"] == "response.done" + + def test_endpoints_keep_connection_sessions_separate(self, session_start_frame) -> None: + app = VoiceAgentServerHost(configure_observability=None) + sessions = [] + + @app.on_session_start + async def start(session: Session, _event: SessionStart) -> None: + sessions.append(session) + await session.send(SessionReady()) + + @app.on_user_message + async def respond(session: Session, event: UserMessage) -> None: + await session.send(ResponseCreated(response_id=f"r_{event.item_id}", in_reply_to=(event.item_id,))) + await session.send(ResponseDone(response_id=f"r_{event.item_id}")) + + with TestClient(app) as client: + with client.websocket_connect("/invocations_ws") as legacy: + legacy.send_json(session_start_frame) + assert legacy.receive_json()["type"] == "session.ready" + with client.websocket_connect("/voice_conversation_engine") as ce: + ce.send_json(session_start_frame) + assert ce.receive_json()["type"] == "session.ready" + ce.send_json( + { + "type": "user.message", + "id": "m_ce", + "ts": "2026-10-15T00:00:01Z", + "item_id": "in_ce", + "content": [{"type": "input_text", "text": "CE"}], + } + ) + assert ce.receive_json()["response_id"] == "r_in_ce" + assert ce.receive_json()["type"] == "response.done" + # Closing a CE connection must not close or replace the legacy session. + legacy.send_json( + { + "type": "user.message", + "id": "m_legacy", + "ts": "2026-10-15T00:00:02Z", + "item_id": "in_legacy", + "content": [{"type": "input_text", "text": "Legacy"}], + } + ) + assert legacy.receive_json()["response_id"] == "r_in_legacy" + assert legacy.receive_json()["type"] == "response.done" + assert len(sessions) == 2 + assert sessions[0] is not sessions[1] + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_both_routes_reject_same_scope_conflicts(self, protocol: str) -> None: + async def conflicting_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + + path = f"/{protocol}" + with pytest.raises(RuntimeError, match=re.escape(f"cannot own {path}")): + VoiceAgentServerHost( + routes=[WebSocketRoute(path, conflicting_endpoint)], + configure_observability=None, + ) + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_both_routes_recheck_live_conflicts(self, protocol: str) -> None: + app = VoiceAgentServerHost(configure_observability=None) + + async def conflicting_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + + path = f"/{protocol}" + app.router.routes.append(WebSocketRoute(path, conflicting_endpoint)) + with pytest.raises(RuntimeError, match=re.escape(f"cannot own {path}")): + with TestClient(app).websocket_connect(path): + pytest.fail("A conflicting route must not be accepted") + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + @pytest.mark.parametrize("authority_kind", ["host", "mount"]) + def test_both_routes_preserve_host_and_mount_authority(self, protocol: str, authority_kind: str) -> None: + async def authority_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_text("authority") + + path = f"/{protocol}" + nested = Starlette(routes=[WebSocketRoute(path, authority_endpoint)]) + authority = Host("tenant.example", app=nested) if authority_kind == "host" else Mount("/", app=nested) + app = VoiceAgentServerHost(routes=[authority], configure_observability=None) + headers = {"host": "tenant.example"} if authority_kind == "host" else {} + with TestClient(app).websocket_connect(path, headers=headers) as websocket: + assert websocket.receive_text() == "authority" + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_both_routes_precede_live_catch_all(self, protocol: str, session_start_frame) -> None: + async def catch_all(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_text("catch_all") + + app = VoiceAgentServerHost(configure_observability=None) + app.router.routes.insert(0, WebSocketRoute("/{path:path}", catch_all)) + + @app.on_session_start + async def start(session: Session, _event: SessionStart) -> None: + await session.send(SessionReady()) + + with TestClient(app) as client: + with client.websocket_connect(f"/{protocol}") as websocket: + websocket.send_json(session_start_frame) + assert websocket.receive_json()["type"] == "session.ready" + with client.websocket_connect("/other") as websocket: + assert websocket.receive_text() == "catch_all" + paths = [route.path for route in app.routes if isinstance(route, WebSocketRoute)] + assert paths == ["/invocations_ws", "/voice_conversation_engine", "/{path:path}"] + + def test_generic_invocations_host_still_exposes_only_raw_ws(self) -> None: + app = InvocationAgentServerHost(configure_observability=None) + + @app.ws_handler + async def raw_ws(websocket: WebSocket) -> None: + await websocket.send_text("raw protocol") + + with TestClient(app) as client: + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_text() == "raw protocol" + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/voice_conversation_engine"): + pytest.fail("A raw WS handler must not enable CE") + + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_both_endpoints_preserve_reconnect_contract(self, protocol: str, session_start_frame) -> None: + app = VoiceAgentServerHost(configure_observability=None) + reconnects = [] + + @app.on_session_start + async def start(session: Session, event: SessionStart) -> None: + reconnects.append(event.reconnect) + assert event.protocol_version == "1.0" + await session.send(SessionReady()) + + with TestClient(app) as client: + for reconnect in (False, True): + with client.websocket_connect(f"/{protocol}") as websocket: + websocket.send_json({**session_start_frame, "reconnect": reconnect}) + assert websocket.receive_json()["type"] == "session.ready" + assert reconnects == [False, True] + + def test_typed_host_keeps_legacy_raw_handler_replacement_error(self) -> None: + app = VoiceAgentServerHost(configure_observability=None) + + async def raw_ws(_websocket: WebSocket) -> None: + pass + + with pytest.raises( + RuntimeError, match="^VoiceAgentServerHost owns /invocations_ws; use on_ decorators$" + ): + app.ws_handler(raw_ws) diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_protocol_findings.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_protocol_findings.py index 86a343eb821c..3d0476b19179 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_protocol_findings.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_protocol_findings.py @@ -14,11 +14,19 @@ from azure.ai.agentserver.invocations.voice import _voice_host as voice_host_module from azure.ai.agentserver.invocations.voice import ( + InputDataPart, ResponseCreated, + ResponseDone, + ResponseNone, + ResponseOutputDataDone, + Session, SessionEnd, SessionReady, + SessionStart, SessionTermination, + UserBargeIn, UserSpeechStarted, + UserMessage, VoiceAgentServerHost, ) from azure.ai.agentserver.invocations.voice._codec import MAX_FRAME_BYTES, encode_outbound_message @@ -26,6 +34,113 @@ from conftest import _records_with_ws_extras +class TestUserBargeInDispatch: + @pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) + def test_only_user_barge_in_reaches_callback_with_playback_snapshot(self, protocol: str) -> None: + app = VoiceAgentServerHost(configure_observability=None) + interruptions: list[UserBargeIn] = [] + + @app.on_session_start + async def start(session: Session, _event: SessionStart) -> None: + await session.send(SessionReady()) + + @app.on_user_barge_in + async def interrupted(_session: Session, event: UserBargeIn) -> None: + interruptions.append(event) + + @app.on_user_message + async def barrier(session: Session, event: UserMessage) -> None: + await session.send(ResponseNone(in_reply_to=(event.item_id,))) + + with TestClient(app).websocket_connect(f"/{protocol}") as websocket: + websocket.send_text( + _voice_frame( + "session.start", + "m_start", + protocol_version="1.0", + reconnect=False, + response_timeouts={"first_output_ms": 1000, "idle_ms": 1000, "max_duration_ms": 5000}, + )["text"] + ) + assert websocket.receive_json()["type"] == "session.ready" + for message_type in ("barge_in", "user.barge_in"): + websocket.send_text( + _voice_frame( + message_type, + f"m_{message_type}", + response_id="r_reply", + item_id="it_reply", + heard_text="heard prefix", + )["text"] + ) + websocket.send_text( + _voice_frame( + "user.message", + "m_barrier", + item_id="in_barrier", + content=[{"type": "input_text", "text": "next"}], + )["text"] + ) + assert websocket.receive_json()["type"] == "response.none" + assert len(interruptions) == 1 + interruption = interruptions[0] + assert interruption.type == "user.barge_in" + assert interruption.response_id == "r_reply" + assert interruption.item_id == "it_reply" + assert interruption.heard_text == "heard prefix" + + +@pytest.mark.parametrize("silent", [False, True]) +@pytest.mark.parametrize("protocol", ["invocations_ws", "voice_conversation_engine"]) +def test_data_and_silent_completion_over_websocket(silent: bool, protocol: str) -> None: + app = VoiceAgentServerHost(configure_observability=None) + + @app.on_session_start + async def start(session: Session, event: SessionStart) -> None: + assert event.protocol_version == "1.0" + await session.send(SessionReady()) + + @app.on_user_message + async def data(session: Session, event: UserMessage) -> None: + if silent: + await session.send(ResponseNone(in_reply_to=(event.item_id,), reason="acknowledged")) + return + await session.send(ResponseCreated(response_id="r_data", in_reply_to=(event.item_id,))) + part = event.content[0] + assert isinstance(part, InputDataPart) + await session.send(ResponseOutputDataDone(response_id="r_data", item_id="it_data", data=part.data)) + await session.send(ResponseDone(response_id="r_data")) + + with TestClient(app).websocket_connect(f"/{protocol}") as websocket: + websocket.send_text( + _voice_frame( + "session.start", + "m_start", + protocol_version="1.0", + reconnect=False, + response_timeouts={"first_output_ms": 1000, "idle_ms": 1000, "max_duration_ms": 5000}, + )["text"] + ) + assert websocket.receive_json()["type"] == "session.ready" + payload = {"type": "hubagent.metadata", "data": [True, None, {"key": "value"}]} + websocket.send_text( + _voice_frame( + "user.message", + "m_data", + item_id="in_data", + content=[{"type": "input_data", "data": payload}], + )["text"] + ) + first = websocket.receive_json() + if silent: + assert first["type"] == "response.none" + assert first["in_reply_to"] == ["in_data"] + else: + assert first["type"] == "response.created" + assert websocket.receive_json()["data"] == payload + assert websocket.receive_json()["type"] == "response.done" + + def _voice_frame(message_type, message_id, **fields): return { "type": "websocket.receive", diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_tracing.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_tracing.py index 5515429e320f..239364f4200e 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_tracing.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_tracing.py @@ -10,6 +10,7 @@ import subprocess import sys from pathlib import Path +from urllib.parse import unquote import pytest from opentelemetry import baggage, metrics, trace @@ -22,6 +23,7 @@ from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect +from azure.ai.agentserver.core import get_request_context from azure.ai.agentserver.invocations._constants import InvocationsWSConstants from azure.ai.agentserver.invocations.voice import ( Session, @@ -142,6 +144,91 @@ def _session_start_frame() -> dict[str, object]: } +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +def test_voice_original_subject_is_invocation_local(path): + app = VoiceAgentServerHost(configure_observability=None) + observed = [] + + @app.on_session_start + async def on_session_start(session, _event): + observed.append(get_request_context().model_attribution_headers()) + await session.send(SessionReady()) + + subject = {"x-ms-original-subject": 'oid="voice-user", tid="tenant", idtype="user"'} + client = TestClient(app) + for headers in (subject, {}): + with client.websocket_connect(path, headers=dict(headers)) as websocket: + websocket.send_json(_session_start_frame()) + assert websocket.receive_json()["type"] == "session.ready" + assert len(observed) == 2 + assert not any(name.lower().startswith("x-ms-original-subject") for name in observed[0]) + assert unquote(observed[0]["x-ms-foundry-baggage"].split("=", 1)[1]) == subject["x-ms-original-subject"] + assert len(observed[0]) == 1 + assert observed[1] == {} + assert get_request_context().original_subject is None + + +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +def test_overlapping_voice_sessions_keep_subject_baggage_isolated(path): + app = VoiceAgentServerHost(configure_observability=None) + ready = asyncio.Event() + observed = [] + arrivals = 0 + + @app.on_session_start + async def on_session_start(session, _event): + nonlocal arrivals + context = get_request_context() + arrivals += 1 + if arrivals == 2: + ready.set() + await asyncio.wait_for(ready.wait(), timeout=5) + assert get_request_context() is context + observed.append(context.model_attribution_headers()) + await session.send(SessionReady()) + + alice = 'oid="alice", tid="tenant-alice", idtype="user"' + bob = 'oid="bob", tid="tenant-bob", idtype="app"' + with TestClient(app) as client: + with client.websocket_connect(path, headers={"x-ms-original-subject": alice}) as first: + with client.websocket_connect(path, headers={"x-ms-original-subject": bob}) as second: + first.send_json(_session_start_frame()) + second.send_json(_session_start_frame()) + assert first.receive_json()["type"] == "session.ready" + assert second.receive_json()["type"] == "session.ready" + with client.websocket_connect(path) as without_subject: + without_subject.send_json(_session_start_frame()) + assert without_subject.receive_json()["type"] == "session.ready" + assert len(observed) == 3 + assert {unquote(headers["x-ms-foundry-baggage"].split("=", 1)[1]) for headers in observed[:2]} == {alice, bob} + for headers in observed[:2]: + assert not any(name.lower().startswith("x-ms-original-subject") for name in headers) + assert observed[2] == {} + assert get_request_context().original_subject is None + + +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +def test_voice_malformed_original_subject_closes_without_callback(path, caplog): + app = VoiceAgentServerHost(configure_observability=None) + observed = [] + + @app.on_session_start + async def on_session_start(_session, _event): + observed.append(True) + + with caplog.at_level(logging.INFO, logger="azure.ai.agentserver"): + with TestClient(app).websocket_connect(path, headers={"x-ms-original-subject": "malformed"}) as websocket: + with pytest.raises(WebSocketDisconnect) as error: + websocket.receive_json() + assert error.value.code == 1011 + assert not observed + assert any( + getattr(record, InvocationsWSConstants.ATTR_SPAN_ERROR_CODE, None) == "invalid_original_subject" + for record in caplog.records + ) + assert get_request_context().original_subject is None + + def _span_by_name(exporter: InMemorySpanExporter, name: str): matches = [span for span in exporter.get_finished_spans() if span.name == name] assert len(matches) == 1, [span.name for span in exporter.get_finished_spans()] diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_transport_findings.py b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_transport_findings.py index af9b3408169e..f4c097b3a39a 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_transport_findings.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/tests/voice/test_voice_transport_findings.py @@ -608,6 +608,158 @@ async def send(message): assert set(session_module._CLOSE_ATTEMPTS) == baseline_attempts # pylint: disable=protected-access +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +@pytest.mark.parametrize("accept_failure", [None, "before_commit", "after_commit"]) +async def test_voice_malformed_metadata_upgrades_before_close(path, accept_failure, monkeypatch): + app = VoiceAgentServerHost(configure_observability=None) + observed = [] + sent_messages = [] + close_events = [] + inbound_events = [ + {"type": "websocket.connect"}, + {"type": "websocket.receive", "text": json.dumps(_session_start_frame())}, + {"type": "websocket.disconnect", "code": 1000}, + ] + websocket = _websocket_with_headers([(b"x-ms-original-subject", b"malformed")]) + scope = dict(websocket.scope, path=path, raw_path=path.encode("ascii")) + + @app.on_session_start + async def on_session_start(_session, _event): + observed.append("session_start") + + @app.on_disconnect + async def on_disconnect(_session, _event): + observed.append("disconnect") + + def create_session(*_args, **_kwargs): + pytest.fail("Malformed metadata must not create a Voice Session.") + + monkeypatch.setattr(Session, "_create", create_session) + + async def receive(): + if accept_failure == "before_commit": + raise OSError("accept failed before commit") + return inbound_events.pop(0) + + async def send(message): + sent_messages.append(message) + if message["type"] == "websocket.accept" and accept_failure == "after_commit": + raise OSError("accept failed after commit") + + app._emit_close_event = ( # type: ignore[method-assign] # pylint: disable=protected-access + lambda _session_id, code, _duration_ms, *, error_code=None: close_events.append((code, error_code)) + ) + + await asyncio.wait_for(app(scope, receive, send), timeout=1) + + if accept_failure == "before_commit": + assert sent_messages == [] + else: + assert [message["type"] for message in sent_messages] == ["websocket.accept", "websocket.close"] + assert sent_messages[-1] == { + "type": "websocket.close", + "code": 1011, + "reason": "Internal server error", + } + assert close_events == [(1011, "accept_failed" if accept_failure else "invalid_original_subject")] + assert not observed + assert get_request_context().original_subject is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +async def test_voice_malformed_metadata_stalled_close_is_bounded(path, monkeypatch): + monkeypatch.setattr(session_module, "CLOSE_TIMEOUT_SECONDS", 0.01) + app = VoiceAgentServerHost(configure_observability=None) + websocket = _websocket_with_headers([(b"x-ms-original-subject", b"malformed")]) + scope = dict(websocket.scope, path=path, raw_path=path.encode("ascii")) + close_started = asyncio.Event() + close_release = asyncio.Event() + close_events = [] + sent_messages = [] + baseline_attempts = set(session_module._CLOSE_ATTEMPTS) # pylint: disable=protected-access + + def create_session(*_args, **_kwargs): + pytest.fail("Malformed metadata must not create a Voice Session.") + + monkeypatch.setattr(Session, "_create", create_session) + app._emit_close_event = ( # type: ignore[method-assign] # pylint: disable=protected-access + lambda _session_id, code, _duration_ms, *, error_code=None: close_events.append((code, error_code)) + ) + + async def receive(): + return {"type": "websocket.connect"} + + async def send(message): + sent_messages.append(message) + if message["type"] == "websocket.close": + assert get_request_context().original_subject is None + close_started.set() + await close_release.wait() + + endpoint = asyncio.create_task(app(scope, receive, send)) + try: + await asyncio.wait_for(close_started.wait(), timeout=1) + await asyncio.wait_for(asyncio.shield(endpoint), timeout=1) + assert [message["type"] for message in sent_messages] == ["websocket.accept", "websocket.close"] + assert sent_messages[-1]["code"] == 1011 + assert close_events == [(1011, "invalid_original_subject")] + assert session_module._VOICE_SESSION_SCOPE_KEY not in scope # pylint: disable=protected-access + assert get_request_context().original_subject is None + finally: + close_release.set() + await asyncio.gather(endpoint, return_exceptions=True) + await _finish_close_attempts(baseline_attempts) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +@pytest.mark.parametrize("close_cancellation", ["direct", "wrapped", "consumed"]) +async def test_voice_malformed_metadata_close_preserves_cancellation(path, close_cancellation): + app = VoiceAgentServerHost(configure_observability=None) + websocket = _websocket_with_headers([(b"x-ms-original-subject", b"malformed")]) + scope = dict(websocket.scope, path=path, raw_path=path.encode("ascii")) + close_started = asyncio.Event() + close_release = asyncio.Event() + close_events = [] + baseline_attempts = set(session_module._CLOSE_ATTEMPTS) # pylint: disable=protected-access + app._emit_close_event = ( # type: ignore[method-assign] # pylint: disable=protected-access + lambda _session_id, code, _duration_ms, *, error_code=None: close_events.append((code, error_code)) + ) + + async def receive(): + return {"type": "websocket.connect"} + + async def send(message): + if message["type"] != "websocket.close": + return + close_started.set() + try: + await close_release.wait() + except asyncio.CancelledError as cancellation: + if close_cancellation == "wrapped": + raise OSError("transport wrapped cancellation") from cancellation + if close_cancellation == "consumed": + return + raise + + endpoint = asyncio.create_task(app(scope, receive, send)) + try: + await asyncio.wait_for(close_started.wait(), timeout=1) + endpoint.cancel("malformed-close-cancel") + with pytest.raises(asyncio.CancelledError) as raised: + await asyncio.wait_for(endpoint, timeout=1) + assert raised.value.args == (("malformed-close-cancel",) if sys.version_info >= (3, 11) else ()) + assert close_events == [(1011, "cancelled")] + assert session_module._VOICE_SESSION_SCOPE_KEY not in scope # pylint: disable=protected-access + assert get_request_context().original_subject is None + finally: + close_release.set() + await asyncio.gather(endpoint, return_exceptions=True) + await _finish_close_attempts(baseline_attempts) + + @pytest.mark.asyncio async def test_voice_accept_unknown_commit_fails_closed(): app = VoiceAgentServerHost(configure_observability=None) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index 0156182bd201..9c2be88bf9c5 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -1,5 +1,160 @@ # Release History +## 2.5.0b1 (Unreleased) + +### Features Added + +### Breaking Changes + +### Bugs Fixed + +- Remove the exact caller-scoped runtime record when deferred resilient-task + activation fails, rather than retaining an execution that never started. + +### Other Changes + +- Require Core `2.4.0b1` or later for snapshot task deferral/rollback handling. + Publish that Core version before this package. +- Require Core's snapshot lifecycle integration. While task startup is deferred + (`FOUNDRY_MEMORY_SNAPSHOT_ENABLED=true`), enabled-but-uninitialized + task errors are propagated instead of running non-durably; intentional opt-out + and absent/`false` keep the existing in-process fallback. No new HTTP 503 mapping or + author startup-task replay is introduced. + +## 2.4.0 (2026-10-29) + +### Bugs Fixed + +- Removed the broken documentation link from experimental API warnings and docstrings by using the shared Core decorator. + +### Other Changes + +- Stable release of the 2.4.0 preview series. The protocol-scoped task-runtime + behavior and its breaking change from earlier stable releases are documented + in the preview entry below. +- Removed the duplicated experimental decorator compatibility fallback. +- Require the stable `azure-ai-agentserver-core>=2.3.0` release. Publish Core + before Responses. + +## 2.4.0b2 (2026-10-27) + +### Features Added + +- Preserve original-subject assertions in `PlatformContext` and the ambient + request context across streaming and in-process stored Responses execution. +- Responses hosts now request the task runtime when either + `resilient_background` or `steerable_conversations` is enabled. Steering-only + hosts receive persistent queue/cancel behavior; after a crash the interrupted + response is marked failed without re-running its handler, while persisted + queued turns may continue as new steered turns. + +### Bugs Fixed + +- Classify malformed platform attribution headers and end the request span + before returning an error, without invoking the handler. +- Bind recovery callbacks and task options to their owning Responses host so + constructing another host cannot redirect recovered work to its handler or + response provider. +- Do not recover Responses tasks when both Responses features are disabled, + including when Invocations enables a shared task manager. +- Keep eager Responses task registration host-bound so a separate + Invocations-only host cannot recover another host's Responses work. + +### Breaking Changes + +- Responses task-runtime activation is now protocol-scoped. The Core + `set_resilient_tasks_enabled` switch no longer force-enables Responses when + both Responses options are disabled, and Responses activation does not + activate another protocol in a mixed host. + +### Other Changes + +- Raised the minimum `azure-ai-agentserver-core` dependency to `2.3.0b2` for + protocol-scoped task-runtime support and original-subject attribution. + Publish that Core version before this package. + +## 2.4.0b1 (2026-10-15) + +### Bugs Fixed + +- Propagate source cancellation through SSE keep-alive streams instead of + treating interrupted durable admission as successful stream completion. +- Signal accepted durable task shutdown before awaiting response execution, so + explicit and checkpoint-based recovery exits preserve recoverable input. + Rebind cold-recovery cancellation to the handler's exact event before + publication, allowing cancellation to finish the tracked execution. +- Atomically close fresh response reservations and unpublished execution + admission when shutdown takes its drain snapshot. Preserve exact accepted + executions for terminal finalization, track handler work before its first + event, and return the existing HTTP 503 shutdown response for late creates. +- Validate durable user partitions as strings or `None` before constructing + platform, lifecycle, or runtime identities. Invalid partitions never fall + back to anonymous storage. Fail the response only with independently proven + original task references and current-input/deletion guards; otherwise settle + only the malformed input and leave response, replay, and references untouched. +- Allow only the current execution's exact record or request context to finalize + while DELETE checks eligibility, so rejected in-flight DELETE requests cannot + suppress terminal SSE events or response persistence. +- Reject runtime publication once DELETE retains cleanup ownership, and bind + fresh admission to its exact request context so late callbacks cannot replace + a successor or clear a completed deletion marker. +- Partition local in-memory and file-backed responses, items, history, and + conversation indexes by the platform user key, keeping anonymous local state + separate. +- Release exact caller-scoped incarnation references when durable inputs settle + before handler dispatch, preserving successor entries and admission deferrals. +- Read retained DELETE cleanup ownership through the authorized response + provider and noncreating replay lookup, without allocating replacement streams. +- Retire stopped nonterminal runtime executions under scoped recovery admission, + preventing early settlement or cancellation from retaining stale live state. +- Settle malformed current durable response incarnations as failed without + re-invoking execution, while retaining deletion fences and rejecting stale inputs. +- Check all durable task owners before reclaiming empty replay or fencing DELETE, + including older conversation tasks beyond the first listing page. +- Partition live execution, cancellation state, SSE streams, and resilient + task identifiers by the platform user key. Different users can use the same + public response ID independently. Duplicate creation within one user's + partition returns HTTP 409 while execution or replay is retained. +- Require a successful user-scoped provider lookup before replaying stored SSE + events; storage errors no longer fall through to cached replay. +- Require Core 2.3.0b1 so retained file replay is discovered after restart for + duplicate admission, authorized replay, and deletion. +- Return an error when replay cleanup fails during DELETE and retain ownership + state so the owner can retry cleanup. +- Reserve the caller-scoped response ID throughout DELETE cleanup so a new + POST cannot reuse the ID before stream, runtime, and provider cleanup finish. +- Finish retained replay with the persisted terminal snapshot when crash + recovery cannot resume execution, preventing indefinite SSE waits. +- Remove newly allocated replay streams when durable task admission fails or + shutdown rejects the request before execution starts, allowing response ID + reuse after cleanup. Emit startup errors only on the requesting HTTP stream + without recreating rejected replay or closing another execution's stream. +- Release streaming create reservations and stop disconnect monitors even + when sending HTTP headers fails before the first body iteration. +- Shield non-streaming create reservation cleanup from request cancellation + so an interrupted request does not prevent later reuse of the response ID. +- Reclaim empty crash-abandoned file replay only when no caller-scoped runtime, + response provider, or durable task retains ownership. +- Retain DELETE ownership through provider cleanup failures and cancellation, + and fence deleted durable response inputs without deleting unrelated turns. + Recovery participates in scoped admission and cannot resurrect deleted responses. +- Scope durable deletion fences and process-local recovery references by a + private response incarnation, allowing a fresh same-ID turn on a retained + conversation task without admitting an old deleted recovery. + +### Breaking Changes + +- `FileResponseStore` now persists data under user-scoped + `partitions-v1/{anonymous|user-hash}` directories. Existing files in the + previous shared layout remain unchanged but are not read or migrated + automatically, because their user ownership cannot be established safely. + Back up and explicitly migrate any required local data into the correct + partition only after independently verifying its ownership. +- Identified users now use private user-scoped stream and task keys. Existing + shared replay logs and task chains are not adopted for those users because + their ownership cannot be verified. Drain active work before upgrading. + Anonymous lifecycle keys retain their previous format. + ## 2.3.0 (2026-10-05) ### Bugs Fixed diff --git a/sdk/agentserver/azure-ai-agentserver-responses/README.md b/sdk/agentserver/azure-ai-agentserver-responses/README.md index da21bd902a6a..bceb1ea88e06 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/README.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/README.md @@ -12,6 +12,15 @@ pip install azure-ai-agentserver-responses This automatically installs `azure-ai-agentserver-core` as a dependency. +The snapshot task integration requires Core `2.4.0b1` or later. For capture, +set `FOUNDRY_MEMORY_SNAPSHOT_ENABLED=true` to defer enabled tasks until the +after-restore hook. Ordinary non-snapshot/local startup uses an absent value or +`false`. Intentional resilience opt-out keeps the subsystem disabled and uses +the non-durable path; while initialization is deferred (the variable is `true`), +an enabled but uninitialized runtime does not silently fall back. +See the [task snapshot design](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-core/docs/memory-snapshot-task-integration.md) +for compatibility requirements and unresolved startup-submission options. + ### Prerequisites - Python 3.10 or later @@ -48,6 +57,35 @@ the full surface. | `DELETE` | `/responses/{response_id}` | Delete a stored response | | `GET` | `/responses/{response_id}/input_items` | List input items (paginated) | +### User isolation and response identifiers + +The host scopes live execution, cancellation, SSE replay, and resilient task +state by `context.platform_context.user_id_key`. Different users can run the +same public response ID independently. Anonymous requests use a separate +partition. The platform must supply trusted identity headers; accepting a +caller-supplied user header without authentication is not an isolation boundary. + +A duplicate `POST /responses` within the same user partition returns +`409 response_id_conflict` while execution or replay is retained. This does not +prevent the owner from retrieving, reconnecting to, or cancelling the existing +response; it is not an idempotent retry of POST. +With file-backed replay, Core 2.3.0b1 or later also discovers retained logs after +restart for admission, authorized replay, and deletion. Missing logs are not +created by lookups, and expired logs no longer reserve the response ID. +DELETE keeps the same user-scoped ID reserved through stream, runtime, and +provider cleanup. New POSTs cannot reuse it midway through deletion; other +users' partitions remain independent. + +Storage providers must also enforce the supplied `PlatformContext` for response, +item, and history operations. Runtime partitioning does not make a shared custom +provider safe. Stored SSE replay requires a successful provider lookup for the +requesting user before accessing that user's stream. + +User-scoped internal identifiers do not change response IDs on the wire. +Legacy shared task chains and replay logs are not used for identified users; +drain active work before upgrading. Anonymous identifiers retain their previous +format for recovery compatibility. + ### TextResponse The simplest way to return text. Handles the full SSE lifecycle automatically (`response.created` → `response.in_progress` → message/content events → `response.completed`): @@ -162,7 +200,32 @@ For detailed handler implementation guidance, see [docs/handler-implementation-g ### Resilience -Crash recovery is **opt-in** via `ResponsesServerOptions(resilient_background=True)`. When opted in, background responses with `store=True` are crash-recoverable: the handler is re-invoked on restart and the recovered context exposes `context.is_recovery == True`. Stream events are persisted incrementally so clients can reconnect and resume from where they left off. Without the opt-in (the default), a crash mid-handler marks the response `failed` instead of re-invoking the handler. For advanced scenarios (metadata checkpointing, multi-turn steering), see the [Resilient Responses Developer Guide](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md). +Responses requests its task runtime at host startup when either +`ResponsesServerOptions(resilient_background=True)` or +`steerable_conversations=True`. This activation is protocol-scoped: Responses +does not read or change Core's process-wide `set_resilient_tasks_enabled` +switch. That switch remains the opt-in for direct Core tasks and Invocations, +and cannot force-enable Responses when both Responses options are `False`. + +With both options at their default `False`, stored responses run non-durably; +there is no Responses recovery-time mark-failed or handler re-invocation. +`steerable_conversations=True` alone enables the persistent steering +queue/cancel backend: after a crash, the interrupted response is marked +`failed` with `code=server_error` and its handler is not re-run, while persisted +queued turns may be promoted as new steered turns. With +`resilient_background=True`, stored background handlers are re-invoked after a +crash; steering remains disabled unless its option is also enabled. Foreground +stored responses are never re-invoked. + +File-backed SSE replay, resilient-provider startup validation, response +checkpoints, and shutdown preservation remain tied to +`resilient_background=True` (with `store=true, background=true` where +applicable); steering alone uses in-memory SSE replay. Request validation and +connection behavior are unchanged: `background=true, store=false` is rejected, +foreground disconnects cancel foreground work, and accepted background work +survives client disconnect. For the complete request matrix and advanced +recovery guidance, see +the [Resilient Responses Developer Guide](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md). ## Examples @@ -276,6 +339,21 @@ app = ResponsesAgentServerHost(options=options) ## Troubleshooting +### In-memory storage identity + +`InMemoryResponseProvider` partitions response envelopes, input/output items, and +history by the exact `PlatformContext.user_id_key` supplied by a trusted host. +Omitting context or setting the user key to `None` selects a separate anonymous +partition for local development, not unrestricted access to named users' data. +Empty and whitespace keys remain distinct; `call_id` does not affect the partition. +Pass the same user key on every related provider operation, including the legacy +execution/replay helpers when used directly. + +The provider does not authenticate callers. Hosts must establish trustworthy +platform context before accessing it. This storage boundary is not complete +end-to-end authorization: runtime routing and the process-wide SSE stream registry +are separate from the provider's envelope, item, and history storage. + ### Common errors - **400 Bad Request**: The request body failed validation. Check that optional fields such as `model` (when provided) are valid and that `input` items are well-formed. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.md b/sdk/agentserver/azure-ai-agentserver-responses/api.md index 110c923a6aec..2343685a672c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.md @@ -1,7 +1,7 @@ ```py namespace azure.ai.agentserver.responses - class azure.ai.agentserver.responses.CreateResponse(TypedDict, total=False): + class azure.ai.agentserver.responses.CreateResponse(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "background": Optional[bool] key "context_management": Optional[list[ContextManagementParam]] @@ -275,6 +275,7 @@ namespace azure.ai.agentserver.responses response_id: str, event: StreamEventRecord, *, + context: PlatformContext | None = ..., ttl_seconds: int | None = ... ) -> bool: ... @@ -282,6 +283,7 @@ namespace azure.ai.agentserver.responses self, execution: ResponseExecution, *, + context: PlatformContext | None = ..., ttl_seconds: int | None = ... ) -> None: ... @@ -294,7 +296,12 @@ namespace azure.ai.agentserver.responses context: PlatformContext | None = ... ) -> None: ... - async def delete(self, response_id: str) -> bool: ... + async def delete( + self, + response_id: str, + *, + context: PlatformContext | None = ... + ) -> bool: ... async def delete_response( self, @@ -303,7 +310,12 @@ namespace azure.ai.agentserver.responses context: PlatformContext | None = ... ) -> None: ... - async def get_execution(self, response_id: str) -> ResponseExecution | None: ... + async def get_execution( + self, + response_id: str, + *, + context: PlatformContext | None = ... + ) -> ResponseExecution | None: ... async def get_history_item_ids( self, @@ -332,7 +344,12 @@ namespace azure.ai.agentserver.responses context: PlatformContext | None = ... ) -> list[OutputItem | None]: ... - async def get_replay_events(self, response_id: str) -> list[StreamEventRecord] | None: ... + async def get_replay_events( + self, + response_id: str, + *, + context: PlatformContext | None = ... + ) -> list[StreamEventRecord] | None: ... async def get_response( self, @@ -351,6 +368,7 @@ namespace azure.ai.agentserver.responses self, response_id: str, *, + context: PlatformContext | None = ..., ttl_seconds: int | None = ... ) -> bool: ... @@ -359,6 +377,7 @@ namespace azure.ai.agentserver.responses response_id: str, response: ResponseObject, *, + context: PlatformContext | None = ..., ttl_seconds: int | None = ... ) -> bool: ... @@ -367,6 +386,7 @@ namespace azure.ai.agentserver.responses response_id: str, next_status: ResponseStatus, *, + context: PlatformContext | None = ..., ttl_seconds: int | None = ... ) -> bool: ... @@ -384,6 +404,7 @@ namespace azure.ai.agentserver.responses self, *, call_id: str | None = ..., + original_subject: FoundryOriginalSubject | None = ..., user_id_key: str | None = ... ) -> None: ... @@ -540,6 +561,7 @@ namespace azure.ai.agentserver.responses *, code: str = "server_error", message: str = "An internal server error occurred.", + metadata: Mapping[str, str] | None = ..., usage: ResponseUsage | None = ... ) -> ResponseFailedEvent: ... @@ -670,7 +692,7 @@ namespace azure.ai.agentserver.responses class azure.ai.agentserver.responses.ResponseExitForRecovery(BaseException): - class azure.ai.agentserver.responses.ResponseObject(TypedDict, total=False): + class azure.ai.agentserver.responses.ResponseObject(TypedDict): key "agent_reference": Required[Optional[AgentReference]] key "background": Optional[bool] key "completed_at": Optional[int] @@ -957,6 +979,7 @@ namespace azure.ai.agentserver.responses.aio *, code: str = "server_error", message: str = "An internal server error occurred.", + metadata: Mapping[str, str] | None = ..., usage: ResponseUsage | None = ... ) -> ResponseFailedEvent: ... @@ -1531,6 +1554,7 @@ namespace azure.ai.agentserver.responses.aio.streaming *, code: str = "server_error", message: str = "An internal server error occurred.", + metadata: Mapping[str, str] | None = ..., usage: ResponseUsage | None = ... ) -> ResponseFailedEvent: ... @@ -1724,7 +1748,7 @@ namespace azure.ai.agentserver.responses.models def azure.ai.agentserver.responses.models.get_tool_choice_expanded(request: CreateResponse) -> dict[str, Any] | None: ... - class azure.ai.agentserver.responses.models.A2APreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.A2APreviewTool(TypedDict): key "agent_card_path": str key "base_url": str key "description": str @@ -1739,7 +1763,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[a2a_preview] - class azure.ai.agentserver.responses.models.A2AToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.A2AToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -1758,7 +1782,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[a2a_preview_call] - class azure.ai.agentserver.responses.models.A2AToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.A2AToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -1777,7 +1801,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[a2a_preview_call_output] - class azure.ai.agentserver.responses.models.AISearchIndexResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AISearchIndexResource(TypedDict): key "description": str key "filter": str key "index_asset_id": str @@ -1796,7 +1820,7 @@ namespace azure.ai.agentserver.responses.models top_k: int - class azure.ai.agentserver.responses.models.AdditionalToolsItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AdditionalToolsItemParam(TypedDict): key "id": Optional[str] key "role": Required[Literal["developer"]] key "tools": Required[list[Tool]] @@ -1807,7 +1831,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[additional_tools] - class azure.ai.agentserver.responses.models.AgentReference(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AgentReference(TypedDict): key "name": Required[str] key "type": Required[Literal["agent_reference"]] key "version": str @@ -1816,12 +1840,12 @@ namespace azure.ai.agentserver.responses.models version: str - class azure.ai.agentserver.responses.models.ApiErrorResponse(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApiErrorResponse(TypedDict): key "error": Required[Error] error: Error - class azure.ai.agentserver.responses.models.ApplyPatchCreateFileOperation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchCreateFileOperation(TypedDict): key "diff": Required[str] key "path": Required[str] key "type": Required[Literal["create_file"]] @@ -1830,7 +1854,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[create_file] - class azure.ai.agentserver.responses.models.ApplyPatchCreateFileOperationParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchCreateFileOperationParam(TypedDict): key "diff": Required[str] key "path": Required[str] key "type": Required[Literal["create_file"]] @@ -1839,21 +1863,21 @@ namespace azure.ai.agentserver.responses.models type: Literal[create_file] - class azure.ai.agentserver.responses.models.ApplyPatchDeleteFileOperation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchDeleteFileOperation(TypedDict): key "path": Required[str] key "type": Required[Literal["delete_file"]] path: str type: Literal[delete_file] - class azure.ai.agentserver.responses.models.ApplyPatchDeleteFileOperationParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchDeleteFileOperationParam(TypedDict): key "path": Required[str] key "type": Required[Literal["delete_file"]] path: str type: Literal[delete_file] - class azure.ai.agentserver.responses.models.ApplyPatchToolCallItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchToolCallItemParam(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] key "id": Optional[str] @@ -1868,7 +1892,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call] - class azure.ai.agentserver.responses.models.ApplyPatchToolCallOutputItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchToolCallOutputItemParam(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] key "id": Optional[str] @@ -1883,14 +1907,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call_output] - class azure.ai.agentserver.responses.models.ApplyPatchToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchToolParam(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "type": Required[Literal["apply_patch"]] allowed_callers: list[CallableToolAllowedCaller] type: Literal[apply_patch] - class azure.ai.agentserver.responses.models.ApplyPatchUpdateFileOperation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchUpdateFileOperation(TypedDict): key "diff": Required[str] key "path": Required[str] key "type": Required[Literal["update_file"]] @@ -1899,7 +1923,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[update_file] - class azure.ai.agentserver.responses.models.ApplyPatchUpdateFileOperationParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApplyPatchUpdateFileOperationParam(TypedDict): key "diff": Required[str] key "path": Required[str] key "type": Required[Literal["update_file"]] @@ -1908,7 +1932,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[update_file] - class azure.ai.agentserver.responses.models.ApproximateLocation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ApproximateLocation(TypedDict): key "city": Optional[str] key "country": Optional[str] key "region": Optional[str] @@ -1921,7 +1945,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[approximate] - class azure.ai.agentserver.responses.models.AutoCodeInterpreterToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AutoCodeInterpreterToolParam(TypedDict): key "memory_limit": Optional[Literal["1g", "4g", "16g", "64g"]] key "network_policy": ForwardRef('ContainerNetworkPolicyParam', module='types') key "type": Required[Literal["auto"]] @@ -1931,7 +1955,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[auto] - class azure.ai.agentserver.responses.models.AzureAISearchTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureAISearchTool(TypedDict): key "azure_ai_search": Required[AzureAISearchToolResource] key "description": str key "name": str @@ -1942,7 +1966,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[azure_ai_search] - class azure.ai.agentserver.responses.models.AzureAISearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureAISearchToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -1959,7 +1983,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[azure_ai_search_call] - class azure.ai.agentserver.responses.models.AzureAISearchToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureAISearchToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -1976,7 +2000,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[azure_ai_search_call_output] - class azure.ai.agentserver.responses.models.AzureAISearchToolResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureAISearchToolResource(TypedDict): key "description": str key "indexes": Required[list[AISearchIndexResource]] key "name": str @@ -1985,14 +2009,14 @@ namespace azure.ai.agentserver.responses.models name: str - class azure.ai.agentserver.responses.models.AzureFunctionBinding(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionBinding(TypedDict): key "storage_queue": Required[AzureFunctionStorageQueue] key "type": Required[Literal["storage_queue"]] storage_queue: AzureFunctionStorageQueue type: Literal[storage_queue] - class azure.ai.agentserver.responses.models.AzureFunctionDefinition(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionDefinition(TypedDict): key "function": Required[AzureFunctionDefinitionFunction] key "input_binding": Required[AzureFunctionBinding] key "output_binding": Required[AzureFunctionBinding] @@ -2001,7 +2025,7 @@ namespace azure.ai.agentserver.responses.models output_binding: AzureFunctionBinding - class azure.ai.agentserver.responses.models.AzureFunctionDefinitionFunction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionDefinitionFunction(TypedDict): key "description": str key "name": Required[str] key "parameters": Required[dict[str, Any]] @@ -2010,21 +2034,21 @@ namespace azure.ai.agentserver.responses.models parameters: dict[str, Any] - class azure.ai.agentserver.responses.models.AzureFunctionStorageQueue(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionStorageQueue(TypedDict): key "queue_name": Required[str] key "queue_service_endpoint": Required[str] queue_name: str queue_service_endpoint: str - class azure.ai.agentserver.responses.models.AzureFunctionTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionTool(TypedDict): key "azure_function": Required[AzureFunctionDefinition] key "type": Required[Literal["azure_function"]] azure_function: AzureFunctionDefinition type: Literal[azure_function] - class azure.ai.agentserver.responses.models.AzureFunctionToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -2043,7 +2067,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[azure_function_call] - class azure.ai.agentserver.responses.models.AzureFunctionToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.AzureFunctionToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -2062,7 +2086,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[azure_function_call_output] - class azure.ai.agentserver.responses.models.BingCustomSearchConfiguration(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingCustomSearchConfiguration(TypedDict): key "count": int key "description": str key "freshness": str @@ -2081,7 +2105,7 @@ namespace azure.ai.agentserver.responses.models set_lang: str - class azure.ai.agentserver.responses.models.BingCustomSearchPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingCustomSearchPreviewTool(TypedDict): key "bing_custom_search_preview": Required[BingCustomSearchToolParameters] key "description": str key "name": str @@ -2092,7 +2116,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_custom_search_preview] - class azure.ai.agentserver.responses.models.BingCustomSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingCustomSearchToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -2109,7 +2133,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_custom_search_preview_call] - class azure.ai.agentserver.responses.models.BingCustomSearchToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingCustomSearchToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -2126,7 +2150,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_custom_search_preview_call_output] - class azure.ai.agentserver.responses.models.BingCustomSearchToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingCustomSearchToolParameters(TypedDict): key "description": str key "name": str key "search_configurations": Required[list[BingCustomSearchConfiguration]] @@ -2135,7 +2159,7 @@ namespace azure.ai.agentserver.responses.models search_configurations: list[BingCustomSearchConfiguration] - class azure.ai.agentserver.responses.models.BingGroundingSearchConfiguration(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingGroundingSearchConfiguration(TypedDict): key "count": int key "description": str key "freshness": str @@ -2152,7 +2176,7 @@ namespace azure.ai.agentserver.responses.models set_lang: str - class azure.ai.agentserver.responses.models.BingGroundingSearchToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingGroundingSearchToolParameters(TypedDict): key "description": str key "name": str key "search_configurations": Required[list[BingGroundingSearchConfiguration]] @@ -2161,7 +2185,7 @@ namespace azure.ai.agentserver.responses.models search_configurations: list[BingGroundingSearchConfiguration] - class azure.ai.agentserver.responses.models.BingGroundingTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingGroundingTool(TypedDict): key "bing_grounding": Required[BingGroundingSearchToolParameters] key "description": str key "name": str @@ -2172,7 +2196,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_grounding] - class azure.ai.agentserver.responses.models.BingGroundingToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingGroundingToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -2189,7 +2213,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_grounding_call] - class azure.ai.agentserver.responses.models.BingGroundingToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BingGroundingToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -2206,7 +2230,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[bing_grounding_call_output] - class azure.ai.agentserver.responses.models.BrowserAutomationPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BrowserAutomationPreviewTool(TypedDict): key "browser_automation_preview": Required[BrowserAutomationToolParameters] key "description": str key "name": str @@ -2217,7 +2241,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[browser_automation_preview] - class azure.ai.agentserver.responses.models.BrowserAutomationToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BrowserAutomationToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -2234,7 +2258,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[browser_automation_preview_call] - class azure.ai.agentserver.responses.models.BrowserAutomationToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BrowserAutomationToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -2251,7 +2275,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[browser_automation_preview_call_output] - class azure.ai.agentserver.responses.models.BrowserAutomationToolConnectionParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BrowserAutomationToolConnectionParameters(TypedDict): key "description": str key "name": str key "project_connection_id": Required[str] @@ -2260,7 +2284,7 @@ namespace azure.ai.agentserver.responses.models project_connection_id: str - class azure.ai.agentserver.responses.models.BrowserAutomationToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.BrowserAutomationToolParameters(TypedDict): key "connection": Required[BrowserAutomationToolConnectionParameters] key "description": str key "name": str @@ -2269,14 +2293,14 @@ namespace azure.ai.agentserver.responses.models name: str - class azure.ai.agentserver.responses.models.CaptureStructuredOutputsTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CaptureStructuredOutputsTool(TypedDict): key "outputs": Required[StructuredOutputDefinition] key "type": Required[Literal["capture_structured_outputs"]] outputs: StructuredOutputDefinition type: Literal[capture_structured_outputs] - class azure.ai.agentserver.responses.models.ChatSummaryMemoryItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ChatSummaryMemoryItem(TypedDict): key "content": Required[str] key "kind": Required[Literal["chat_summary"]] key "memory_id": Required[str] @@ -2289,7 +2313,7 @@ namespace azure.ai.agentserver.responses.models updated_at: int - class azure.ai.agentserver.responses.models.ClickParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ClickParam(TypedDict): key "button": Required[Literal["left", "right", "wheel", "back", "forward"]] key "keys": Optional[list[str]] key "type": Required[Literal["click"]] @@ -2302,21 +2326,21 @@ namespace azure.ai.agentserver.responses.models y: int - class azure.ai.agentserver.responses.models.CodeInterpreterOutputImage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CodeInterpreterOutputImage(TypedDict): key "type": Required[Literal["image"]] key "url": Required[str] type: Literal[image] url: str - class azure.ai.agentserver.responses.models.CodeInterpreterOutputLogs(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CodeInterpreterOutputLogs(TypedDict): key "logs": Required[str] key "type": Required[Literal["logs"]] logs: str type: Literal[logs] - class azure.ai.agentserver.responses.models.CodeInterpreterTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CodeInterpreterTool(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "container": Union[str, AutoCodeInterpreterToolParam] key "description": str @@ -2329,7 +2353,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[code_interpreter] - class azure.ai.agentserver.responses.models.CompactResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CompactResource(TypedDict): key "created_at": Required[int] key "id": Required[str] key "object": Required[Literal["compaction"]] @@ -2342,10 +2366,10 @@ namespace azure.ai.agentserver.responses.models usage: ResponseUsage - class azure.ai.agentserver.responses.models.CompactResponseMethodPublicBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CompactResponseMethodPublicBody(TypedDict): key "input": Optional[Union[str, list[Item]]] key "instructions": Optional[str] - key "model": Required[Optional[Literal["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.5-2026-04-23", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-mini-2026-03-17", "gpt-5.4-nano-2026-03-17", "gpt-5.3-chat-latest", "gpt-5.2", "gpt-5.2-2025-12-11", "gpt-5.2-chat-latest", "gpt-5.2-pro", "gpt-5.2-pro-2025-12-11", "gpt-5.1", "gpt-5.1-2025-11-13", "gpt-5.1-codex", "gpt-5.1-mini", "gpt-5.1-chat-latest", "gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5-2025-08-07", "gpt-5-mini-2025-08-07", "gpt-5-nano-2025-08-07", "gpt-5-chat-latest", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14", "o4-mini", "o4-mini-2025-04-16", "o3", "o3-2025-04-16", "o3-mini", "o3-mini-2025-01-31", "o1", "o1-2024-12-17", "o1-preview", "o1-preview-2024-09-12", "o1-mini", "o1-mini-2024-09-12", "gpt-4o", "gpt-4o-2024-11-20", "gpt-4o-2024-08-06", "gpt-4o-2024-05-13", "gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", "gpt-4o-audio-preview-2024-12-17", "gpt-4o-audio-preview-2025-06-03", "gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-2024-12-17", "gpt-4o-search-preview", "gpt-4o-mini-search-preview", "gpt-4o-search-preview-2025-03-11", "gpt-4o-mini-search-preview-2025-03-11", "chatgpt-4o-latest", "codex-mini-latest", "gpt-4o-mini", "gpt-4o-mini-2024-07-18", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", "gpt-4-0125-preview", "gpt-4-turbo-preview", "gpt-4-1106-preview", "gpt-4-vision-preview", "gpt-4", "gpt-4-0314", "gpt-4-0613", "gpt-4-32k", "gpt-4-32k-0314", "gpt-4-32k-0613", "gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-3.5-turbo-0301", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-16k-0613", "o1-pro", "o1-pro-2025-03-19", "o3-pro", "o3-pro-2025-06-10", "o3-deep-research", "o3-deep-research-2025-06-26", "o4-mini-deep-research", "o4-mini-deep-research-2025-06-26", "computer-use-preview", "computer-use-preview-2025-03-11", "gpt-5.5-pro", "gpt-5.5-pro-2026-04-23", "gpt-5-codex", "gpt-5-pro", "gpt-5-pro-2025-10-06", "gpt-5.1-codex-max", "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest", "gpt-5.6-cyber"]]] + key "model": Required[Optional[Literal["gpt-6-sol", "gpt-6-terra", "gpt-6-luna", "gpt-5", "gpt-5-2026-04-23", "gpt-4", "gpt-4-mini", "gpt-4-nano", "gpt-4-mini-2026-03-17", "gpt-4-nano-2026-03-17", "gpt-3-chat-latest", "gpt-2", "gpt-2-2025-12-11", "gpt-2-chat-latest", "gpt-2-pro", "gpt-2-pro-2025-12-11", "gpt-1", "gpt-1-2025-11-13", "gpt-1-codex", "gpt-1-mini", "gpt-1-chat-latest", "gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5-2025-08-07", "gpt-5-mini-2025-08-07", "gpt-5-nano-2025-08-07", "gpt-5-chat-latest", "gpt-1", "gpt-1-mini", "gpt-1-nano", "gpt-1-2025-04-14", "gpt-1-mini-2025-04-14", "gpt-1-nano-2025-04-14", "o4-mini", "o4-mini-2025-04-16", "o3", "o3-2025-04-16", "o3-mini", "o3-mini-2025-01-31", "o1", "o1-2024-12-17", "o1-preview", "o1-preview-2024-09-12", "o1-mini", "o1-mini-2024-09-12", "gpt-4o", "gpt-4o-2024-11-20", "gpt-4o-2024-08-06", "gpt-4o-2024-05-13", "gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", "gpt-4o-audio-preview-2024-12-17", "gpt-4o-audio-preview-2025-06-03", "gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-2024-12-17", "gpt-4o-search-preview", "gpt-4o-mini-search-preview", "gpt-4o-search-preview-2025-03-11", "gpt-4o-mini-search-preview-2025-03-11", "chatgpt-4o-latest", "codex-mini-latest", "gpt-4o-mini", "gpt-4o-mini-2024-07-18", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", "gpt-4-0125-preview", "gpt-4-turbo-preview", "gpt-4-1106-preview", "gpt-4-vision-preview", "gpt-4", "gpt-4-0314", "gpt-4-0613", "gpt-4-32k", "gpt-4-32k-0314", "gpt-4-32k-0613", "gpt-5-turbo", "gpt-5-turbo-16k", "gpt-5-turbo-0301", "gpt-5-turbo-0613", "gpt-5-turbo-1106", "gpt-5-turbo-0125", "gpt-5-turbo-16k-0613", "o1-pro", "o1-pro-2025-03-19", "o3-pro", "o3-pro-2025-06-10", "o3-deep-research", "o3-deep-research-2025-06-26", "o4-mini-deep-research", "o4-mini-deep-research-2025-06-26", "computer-use-preview", "computer-use-preview-2025-03-11", "gpt-5-pro", "gpt-5-pro-2026-04-23", "gpt-5-codex", "gpt-5-pro", "gpt-5-pro-2025-10-06", "gpt-1-codex-max", "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest", "gpt-6-cyber"]]] key "previous_response_id": Optional[str] key "prompt_cache_key": Optional[str] key "prompt_cache_options": Optional[PromptCacheOptionsParam] @@ -2361,7 +2385,7 @@ namespace azure.ai.agentserver.responses.models service_tier: ServiceTierEnum - class azure.ai.agentserver.responses.models.CompactionSummaryItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CompactionSummaryItemParam(TypedDict): key "encrypted_content": Required[str] key "id": Optional[str] key "type": Required[Literal["compaction"]] @@ -2370,7 +2394,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[compaction] - class azure.ai.agentserver.responses.models.ComparisonFilter(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComparisonFilter(TypedDict): key "key": Required[str] key "type": Required[Literal["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin"]] key "value": Required[Union[str, float, bool, list[Union[str, float]]]] @@ -2379,14 +2403,14 @@ namespace azure.ai.agentserver.responses.models value: Union[str, float, bool, list[Union[str, float]]] - class azure.ai.agentserver.responses.models.CompoundFilter(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CompoundFilter(TypedDict): key "filters": Required[list[Union[ComparisonFilter, Any]]] key "type": Required[Literal["and", "or"]] filters: list[Union[ComparisonFilter, Any]] type: Literal[and, or] - class azure.ai.agentserver.responses.models.ComputerCallOutputItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerCallOutputItemParam(TypedDict): key "acknowledged_safety_checks": Optional[list[ComputerCallSafetyCheckParam]] key "call_id": Required[str] key "id": Optional[str] @@ -2401,7 +2425,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call_output] - class azure.ai.agentserver.responses.models.ComputerCallSafetyCheckParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerCallSafetyCheckParam(TypedDict): key "code": Optional[str] key "id": Required[str] key "message": Optional[str] @@ -2410,7 +2434,7 @@ namespace azure.ai.agentserver.responses.models message: str - class azure.ai.agentserver.responses.models.ComputerScreenshotContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerScreenshotContent(TypedDict): key "detail": Required[Literal["low", "high", "auto", "original"]] key "file_id": Required[Optional[str]] key "image_url": Required[Optional[str]] @@ -2423,7 +2447,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_screenshot] - class azure.ai.agentserver.responses.models.ComputerScreenshotImage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerScreenshotImage(TypedDict): key "file_id": str key "image_url": str key "type": Required[Literal["computer_screenshot"]] @@ -2432,12 +2456,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_screenshot] - class azure.ai.agentserver.responses.models.ComputerTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerTool(TypedDict): key "type": Required[Literal["computer"]] type: Literal[computer] - class azure.ai.agentserver.responses.models.ComputerUsePreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ComputerUsePreviewTool(TypedDict): key "display_height": Required[int] key "display_width": Required[int] key "environment": Required[Literal["windows", "mac", "linux", "ubuntu", "browser"]] @@ -2448,7 +2472,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_use_preview] - class azure.ai.agentserver.responses.models.ContainerAutoParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerAutoParam(TypedDict): key "memory_limit": Optional[Literal["1g", "4g", "16g", "64g"]] key "network_policy": ForwardRef('ContainerNetworkPolicyParam', module='types') key "type": Required[Literal["container_auto"]] @@ -2459,7 +2483,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[container_auto] - class azure.ai.agentserver.responses.models.ContainerFileCitationBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerFileCitationBody(TypedDict): key "container_id": Required[str] key "end_index": Required[int] key "file_id": Required[str] @@ -2474,7 +2498,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[container_file_citation] - class azure.ai.agentserver.responses.models.ContainerNetworkPolicyAllowlistParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerNetworkPolicyAllowlistParam(TypedDict): key "allowed_domains": Required[list[str]] key "type": Required[Literal["allowlist"]] allowed_domains: list[str] @@ -2482,12 +2506,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[allowlist] - class azure.ai.agentserver.responses.models.ContainerNetworkPolicyDisabledParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerNetworkPolicyDisabledParam(TypedDict): key "type": Required[Literal["disabled"]] type: Literal[disabled] - class azure.ai.agentserver.responses.models.ContainerNetworkPolicyDomainSecretParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerNetworkPolicyDomainSecretParam(TypedDict): key "domain": Required[str] key "name": Required[str] key "value": Required[str] @@ -2496,38 +2520,38 @@ namespace azure.ai.agentserver.responses.models value: str - class azure.ai.agentserver.responses.models.ContainerReferenceResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContainerReferenceResource(TypedDict): key "container_id": Required[str] key "type": Required[Literal["container_reference"]] container_id: str type: Literal[container_reference] - class azure.ai.agentserver.responses.models.ContextManagementParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ContextManagementParam(TypedDict): key "compact_threshold": Optional[int] key "type": Required[str] compact_threshold: int type: str - class azure.ai.agentserver.responses.models.ConversationParam_2(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ConversationParam_2(TypedDict): key "id": Required[str] id: str - class azure.ai.agentserver.responses.models.ConversationReference(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ConversationReference(TypedDict): key "id": Required[str] id: str - class azure.ai.agentserver.responses.models.CoordParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CoordParam(TypedDict): key "x": Required[int] key "y": Required[int] x: int y: int - class azure.ai.agentserver.responses.models.CreateResponse(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CreateResponse(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "background": Optional[bool] key "context_management": Optional[list[ContextManagementParam]] @@ -2594,7 +2618,7 @@ namespace azure.ai.agentserver.responses.models user: str - class azure.ai.agentserver.responses.models.CustomGrammarFormatParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CustomGrammarFormatParam(TypedDict): key "definition": Required[str] key "syntax": Required[Literal["lark", "regex"]] key "type": Required[Literal["grammar"]] @@ -2603,12 +2627,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[grammar] - class azure.ai.agentserver.responses.models.CustomTextFormatParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CustomTextFormatParam(TypedDict): key "type": Required[Literal["text"]] type: Literal[text] - class azure.ai.agentserver.responses.models.CustomToolCallOutputResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CustomToolCallOutputResource(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] @@ -2629,7 +2653,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call_output] - class azure.ai.agentserver.responses.models.CustomToolCallResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CustomToolCallResource(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -2654,7 +2678,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call] - class azure.ai.agentserver.responses.models.CustomToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.CustomToolParam(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "defer_loading": bool key "description": str @@ -2669,7 +2693,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom] - class azure.ai.agentserver.responses.models.DeleteResponseResult(TypedDict, total=False): + class azure.ai.agentserver.responses.models.DeleteResponseResult(TypedDict): key "deleted": Required[Literal[True]] key "id": Required[str] key "object": Required[Literal["response"]] @@ -2678,17 +2702,17 @@ namespace azure.ai.agentserver.responses.models object: Literal[response] - class azure.ai.agentserver.responses.models.DirectToolCallCaller(TypedDict, total=False): + class azure.ai.agentserver.responses.models.DirectToolCallCaller(TypedDict): key "type": Required[Literal["direct"]] type: Literal[direct] - class azure.ai.agentserver.responses.models.DirectToolCallCallerParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.DirectToolCallCallerParam(TypedDict): key "type": Required[Literal["direct"]] type: Literal[direct] - class azure.ai.agentserver.responses.models.DoubleClickAction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.DoubleClickAction(TypedDict): key "keys": Required[Optional[list[str]]] key "type": Required[Literal["double_click"]] key "x": Required[int] @@ -2699,7 +2723,7 @@ namespace azure.ai.agentserver.responses.models y: int - class azure.ai.agentserver.responses.models.DragParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.DragParam(TypedDict): key "keys": Optional[list[str]] key "path": Required[list[CoordParam]] key "type": Required[Literal["drag"]] @@ -2708,10 +2732,10 @@ namespace azure.ai.agentserver.responses.models type: Literal[drag] - class azure.ai.agentserver.responses.models.EmptyModelParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.EmptyModelParam(TypedDict): - class azure.ai.agentserver.responses.models.Error(TypedDict, total=False): + class azure.ai.agentserver.responses.models.Error(TypedDict): key "code": Required[Optional[str]] key "message": Required[str] key "param": Optional[str] @@ -2725,7 +2749,7 @@ namespace azure.ai.agentserver.responses.models type: str - class azure.ai.agentserver.responses.models.FabricDataAgentToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FabricDataAgentToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -2742,7 +2766,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[fabric_dataagent_preview_call] - class azure.ai.agentserver.responses.models.FabricDataAgentToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FabricDataAgentToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -2759,7 +2783,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[fabric_dataagent_preview_call_output] - class azure.ai.agentserver.responses.models.FabricDataAgentToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FabricDataAgentToolParameters(TypedDict): key "description": str key "name": str description: str @@ -2767,7 +2791,7 @@ namespace azure.ai.agentserver.responses.models project_connections: list[ToolProjectConnection] - class azure.ai.agentserver.responses.models.FileCitationBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FileCitationBody(TypedDict): key "file_id": Required[str] key "filename": Required[str] key "index": Required[int] @@ -2778,7 +2802,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[file_citation] - class azure.ai.agentserver.responses.models.FilePath(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FilePath(TypedDict): key "file_id": Required[str] key "index": Required[int] key "type": Required[Literal["file_path"]] @@ -2787,7 +2811,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[file_path] - class azure.ai.agentserver.responses.models.FileSearchTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FileSearchTool(TypedDict): key "description": str key "filters": Optional[Filters] key "max_num_results": int @@ -2804,7 +2828,7 @@ namespace azure.ai.agentserver.responses.models vector_store_ids: list[str] - class azure.ai.agentserver.responses.models.FileSearchToolCallResults(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FileSearchToolCallResults(TypedDict): key "attributes": Optional[VectorStoreFileAttributes] key "file_id": str key "filename": str @@ -2817,7 +2841,7 @@ namespace azure.ai.agentserver.responses.models text: str - class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputFileContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputFileContent(TypedDict): key "detail": Literal["auto", "low", "high"] key "file_data": str key "file_id": Optional[str] @@ -2834,7 +2858,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_file] - class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputImageContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputImageContent(TypedDict): key "detail": Required[Literal["low", "high", "auto", "original"]] key "file_id": Optional[str] key "image_url": Optional[str] @@ -2847,7 +2871,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_image] - class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionAndCustomToolCallOutputInputTextContent(TypedDict): key "prompt_cache_breakpoint": ForwardRef('PromptCacheBreakpointConfig', module='types') key "text": Required[str] key "type": Required[Literal["input_text"]] @@ -2856,7 +2880,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_text] - class azure.ai.agentserver.responses.models.FunctionCallOutputItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionCallOutputItemParam(TypedDict): key "call_id": Optional[str] key "caller": Optional[ToolCallCallerParam] key "id": Optional[str] @@ -2875,7 +2899,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call_output] - class azure.ai.agentserver.responses.models.FunctionShellAction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellAction(TypedDict): key "commands": Required[list[str]] key "max_output_length": Required[Optional[int]] key "timeout_ms": Required[Optional[int]] @@ -2884,7 +2908,7 @@ namespace azure.ai.agentserver.responses.models timeout_ms: int - class azure.ai.agentserver.responses.models.FunctionShellActionParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellActionParam(TypedDict): key "commands": Required[list[str]] key "max_output_length": Optional[int] key "timeout_ms": Optional[int] @@ -2893,7 +2917,7 @@ namespace azure.ai.agentserver.responses.models timeout_ms: int - class azure.ai.agentserver.responses.models.FunctionShellCallItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallItemParam(TypedDict): key "action": Required[FunctionShellActionParam] key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] @@ -2910,20 +2934,20 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call] - class azure.ai.agentserver.responses.models.FunctionShellCallItemParamEnvironmentContainerReferenceParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallItemParamEnvironmentContainerReferenceParam(TypedDict): key "container_id": Required[str] key "type": Required[Literal["container_reference"]] container_id: str type: Literal[container_reference] - class azure.ai.agentserver.responses.models.FunctionShellCallItemParamEnvironmentLocalEnvironmentParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallItemParamEnvironmentLocalEnvironmentParam(TypedDict): key "type": Required[Literal["local"]] skills: list[LocalSkillParam] type: Literal[local] - class azure.ai.agentserver.responses.models.FunctionShellCallOutputContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputContent(TypedDict): key "created_by": str key "outcome": Required[FunctionShellCallOutputOutcome] key "stderr": Required[str] @@ -2934,7 +2958,7 @@ namespace azure.ai.agentserver.responses.models stdout: str - class azure.ai.agentserver.responses.models.FunctionShellCallOutputContentParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputContentParam(TypedDict): key "outcome": Required[FunctionShellCallOutputOutcomeParam] key "stderr": Required[str] key "stdout": Required[str] @@ -2943,21 +2967,21 @@ namespace azure.ai.agentserver.responses.models stdout: str - class azure.ai.agentserver.responses.models.FunctionShellCallOutputExitOutcome(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputExitOutcome(TypedDict): key "exit_code": Required[int] key "type": Required[Literal["exit"]] exit_code: int type: Literal[exit] - class azure.ai.agentserver.responses.models.FunctionShellCallOutputExitOutcomeParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputExitOutcomeParam(TypedDict): key "exit_code": Required[int] key "type": Required[Literal["exit"]] exit_code: int type: Literal[exit] - class azure.ai.agentserver.responses.models.FunctionShellCallOutputItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputItemParam(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] key "id": Optional[str] @@ -2974,17 +2998,17 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call_output] - class azure.ai.agentserver.responses.models.FunctionShellCallOutputTimeoutOutcome(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputTimeoutOutcome(TypedDict): key "type": Required[Literal["timeout"]] type: Literal[timeout] - class azure.ai.agentserver.responses.models.FunctionShellCallOutputTimeoutOutcomeParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellCallOutputTimeoutOutcomeParam(TypedDict): key "type": Required[Literal["timeout"]] type: Literal[timeout] - class azure.ai.agentserver.responses.models.FunctionShellToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellToolParam(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "description": str key "environment": Optional[FunctionShellToolParamEnvironment] @@ -2997,20 +3021,20 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell] - class azure.ai.agentserver.responses.models.FunctionShellToolParamEnvironmentContainerReferenceParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellToolParamEnvironmentContainerReferenceParam(TypedDict): key "container_id": Required[str] key "type": Required[Literal["container_reference"]] container_id: str type: Literal[container_reference] - class azure.ai.agentserver.responses.models.FunctionShellToolParamEnvironmentLocalEnvironmentParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionShellToolParamEnvironmentLocalEnvironmentParam(TypedDict): key "type": Required[Literal["local"]] skills: list[LocalSkillParam] type: Literal[local] - class azure.ai.agentserver.responses.models.FunctionTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionTool(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "defer_loading": bool key "description": Optional[str] @@ -3029,7 +3053,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function] - class azure.ai.agentserver.responses.models.FunctionToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.FunctionToolParam(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "defer_loading": bool key "description": Optional[str] @@ -3048,20 +3072,20 @@ namespace azure.ai.agentserver.responses.models type: Literal[function] - class azure.ai.agentserver.responses.models.HybridSearchOptions(TypedDict, total=False): + class azure.ai.agentserver.responses.models.HybridSearchOptions(TypedDict): key "embedding_weight": Required[float] key "text_weight": Required[float] embedding_weight: float text_weight: float - class azure.ai.agentserver.responses.models.ImageGenTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ImageGenTool(TypedDict): key "action": Literal["generate", "edit", "auto"] key "background": Literal["transparent", "opaque", "auto"] key "description": str key "input_fidelity": Optional[Literal["high", "low"]] key "input_image_mask": ForwardRef('ImageGenToolInputImageMask', module='types') - key "model": Union[Literal["gpt-image-1"], Literal["gpt-image-1-mini"], Literal["gpt-image-1.5"], str] + key "model": Union[Literal["gpt-image-1"], Literal["gpt-image-1-mini"], Literal["gpt-image-5"], str] key "moderation": Literal["auto", "low"] key "name": str key "output_compression": int @@ -3075,7 +3099,7 @@ namespace azure.ai.agentserver.responses.models description: str input_fidelity: InputFidelity input_image_mask: ImageGenToolInputImageMask - model: Union[Literal[gpt-image-1], Literal[gpt-image-1-mini], Literal[gpt-image-1.5], str] + model: Union[Literal[gpt-image-1], Literal[gpt-image-1-mini], Literal[gpt-image-5], str] moderation: Literal[auto, low] name: str output_compression: int @@ -3086,14 +3110,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[image_generation] - class azure.ai.agentserver.responses.models.ImageGenToolInputImageMask(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ImageGenToolInputImageMask(TypedDict): key "file_id": str key "image_url": str file_id: str image_url: str - class azure.ai.agentserver.responses.models.InlineSkillParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InlineSkillParam(TypedDict): key "description": Required[str] key "name": Required[str] key "source": Required[InlineSkillSourceParam] @@ -3104,7 +3128,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[inline] - class azure.ai.agentserver.responses.models.InlineSkillSourceParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InlineSkillSourceParam(TypedDict): key "data": Required[str] key "media_type": Required[Literal["application/zip"]] key "type": Required[Literal["base64"]] @@ -3113,7 +3137,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[base64] - class azure.ai.agentserver.responses.models.InputFileContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputFileContent(TypedDict): key "detail": Literal["auto", "low", "high"] key "file_data": str key "file_id": Optional[str] @@ -3130,7 +3154,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_file] - class azure.ai.agentserver.responses.models.InputFileContentParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputFileContentParam(TypedDict): key "detail": Literal["auto", "low", "high"] key "file_data": Optional[str] key "file_id": Optional[str] @@ -3147,7 +3171,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_file] - class azure.ai.agentserver.responses.models.InputImageContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputImageContent(TypedDict): key "detail": Required[Literal["low", "high", "auto", "original"]] key "file_id": Optional[str] key "image_url": Optional[str] @@ -3160,7 +3184,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_image] - class azure.ai.agentserver.responses.models.InputImageContentParamAutoParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputImageContentParamAutoParam(TypedDict): key "detail": Optional[Literal["low", "high", "auto", "original"]] key "file_id": Optional[str] key "image_url": Optional[str] @@ -3173,7 +3197,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_image] - class azure.ai.agentserver.responses.models.InputTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputTextContent(TypedDict): key "prompt_cache_breakpoint": ForwardRef('PromptCacheBreakpointConfig', module='types') key "text": Required[str] key "type": Required[Literal["input_text"]] @@ -3182,7 +3206,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_text] - class azure.ai.agentserver.responses.models.InputTextContentParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.InputTextContentParam(TypedDict): key "prompt_cache_breakpoint": Optional[PromptCacheBreakpointParam] key "text": Required[str] key "type": Required[Literal["input_text"]] @@ -3191,7 +3215,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_text] - class azure.ai.agentserver.responses.models.ItemCodeInterpreterToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemCodeInterpreterToolCall(TypedDict): key "code": Required[Optional[str]] key "container_id": Required[str] key "id": Required[str] @@ -3206,7 +3230,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[code_interpreter_call] - class azure.ai.agentserver.responses.models.ItemComputerToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemComputerToolCall(TypedDict): key "action": ForwardRef('ComputerAction', module='types') key "call_id": Required[str] key "id": Required[str] @@ -3222,7 +3246,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call] - class azure.ai.agentserver.responses.models.ItemCustomToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemCustomToolCall(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCaller] key "id": str @@ -3239,7 +3263,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call] - class azure.ai.agentserver.responses.models.ItemCustomToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemCustomToolCallOutput(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] key "id": str @@ -3252,7 +3276,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call_output] - class azure.ai.agentserver.responses.models.ItemFieldAdditionalTools(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldAdditionalTools(TypedDict): key "id": Required[str] key "role": Required[Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]] key "tools": Required[list[Tool]] @@ -3263,7 +3287,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[additional_tools] - class azure.ai.agentserver.responses.models.ItemFieldApplyPatchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldApplyPatchToolCall(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCaller] key "created_by": str @@ -3280,7 +3304,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call] - class azure.ai.agentserver.responses.models.ItemFieldApplyPatchToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldApplyPatchToolCallOutput(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCaller] key "created_by": str @@ -3297,7 +3321,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call_output] - class azure.ai.agentserver.responses.models.ItemFieldCodeInterpreterToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldCodeInterpreterToolCall(TypedDict): key "code": Required[Optional[str]] key "container_id": Required[str] key "id": Required[str] @@ -3312,7 +3336,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[code_interpreter_call] - class azure.ai.agentserver.responses.models.ItemFieldCompactionBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldCompactionBody(TypedDict): key "created_by": str key "encrypted_content": Required[str] key "id": Required[str] @@ -3323,7 +3347,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[compaction] - class azure.ai.agentserver.responses.models.ItemFieldComputerToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldComputerToolCall(TypedDict): key "action": ForwardRef('ComputerAction', module='types') key "call_id": Required[str] key "id": Required[str] @@ -3339,7 +3363,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call] - class azure.ai.agentserver.responses.models.ItemFieldComputerToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldComputerToolCallOutput(TypedDict): key "call_id": Required[str] key "id": Required[str] key "output": Required[ComputerScreenshotImage] @@ -3353,7 +3377,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call_output] - class azure.ai.agentserver.responses.models.ItemFieldCustomToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldCustomToolCall(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCaller] key "id": str @@ -3370,7 +3394,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call] - class azure.ai.agentserver.responses.models.ItemFieldCustomToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldCustomToolCallOutput(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCallerParam] key "id": str @@ -3383,7 +3407,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[custom_tool_call_output] - class azure.ai.agentserver.responses.models.ItemFieldFileSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldFileSearchToolCall(TypedDict): key "id": Required[str] key "queries": Required[list[str]] key "results": Optional[list[FileSearchToolCallResults]] @@ -3396,7 +3420,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[file_search_call] - class azure.ai.agentserver.responses.models.ItemFieldFunctionShellCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldFunctionShellCall(TypedDict): key "action": Required[FunctionShellAction] key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -3415,7 +3439,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call] - class azure.ai.agentserver.responses.models.ItemFieldFunctionShellCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldFunctionShellCallOutput(TypedDict): key "call_id": Required[str] key "caller": Optional[ToolCallCaller] key "created_by": str @@ -3434,7 +3458,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call_output] - class azure.ai.agentserver.responses.models.ItemFieldFunctionToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldFunctionToolCall(TypedDict): key "arguments": Required[str] key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -3453,7 +3477,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call] - class azure.ai.agentserver.responses.models.ItemFieldFunctionToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldFunctionToolCallOutput(TypedDict): key "call_id": str key "caller": Optional[ToolCallCallerParam] key "id": Required[str] @@ -3472,7 +3496,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call_output] - class azure.ai.agentserver.responses.models.ItemFieldImageGenToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldImageGenToolCall(TypedDict): key "id": Required[str] key "result": Required[Optional[str]] key "status": Required[Literal["in_progress", "completed", "generating", "failed"]] @@ -3483,7 +3507,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[image_generation_call] - class azure.ai.agentserver.responses.models.ItemFieldLocalShellToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldLocalShellToolCall(TypedDict): key "action": Required[LocalShellExecAction] key "call_id": Required[str] key "id": Required[str] @@ -3496,7 +3520,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call] - class azure.ai.agentserver.responses.models.ItemFieldLocalShellToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldLocalShellToolCallOutput(TypedDict): key "id": Required[str] key "output": Required[str] key "status": Optional[Literal["in_progress", "completed", "incomplete"]] @@ -3507,7 +3531,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call_output] - class azure.ai.agentserver.responses.models.ItemFieldMcpApprovalRequest(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldMcpApprovalRequest(TypedDict): key "arguments": Required[str] key "id": Required[str] key "name": Required[str] @@ -3520,7 +3544,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_request] - class azure.ai.agentserver.responses.models.ItemFieldMcpApprovalResponseResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldMcpApprovalResponseResource(TypedDict): key "approval_request_id": Required[str] key "approve": Required[bool] key "id": Required[str] @@ -3533,7 +3557,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_response] - class azure.ai.agentserver.responses.models.ItemFieldMcpListTools(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldMcpListTools(TypedDict): key "error": ForwardRef('RealtimeMCPError', module='types') key "id": Required[str] key "server_label": Required[str] @@ -3546,7 +3570,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_list_tools] - class azure.ai.agentserver.responses.models.ItemFieldMcpToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldMcpToolCall(TypedDict): key "approval_request_id": Optional[str] key "arguments": Required[str] key "id": Required[str] @@ -3566,7 +3590,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_call] - class azure.ai.agentserver.responses.models.ItemFieldMessage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldMessage(TypedDict): key "content": Required[list[MessageContent]] key "id": Required[str] key "phase": Optional[Literal["commentary", "final_answer"]] @@ -3581,7 +3605,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[message] - class azure.ai.agentserver.responses.models.ItemFieldProgram(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldProgram(TypedDict): key "call_id": Required[str] key "code": Required[str] key "fingerprint": Required[str] @@ -3594,7 +3618,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program] - class azure.ai.agentserver.responses.models.ItemFieldProgramOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldProgramOutput(TypedDict): key "call_id": Required[str] key "id": Required[str] key "result": Required[str] @@ -3607,7 +3631,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program_output] - class azure.ai.agentserver.responses.models.ItemFieldReasoningItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldReasoningItem(TypedDict): key "encrypted_content": Optional[str] key "id": Required[str] key "status": Literal["in_progress", "completed", "incomplete"] @@ -3621,7 +3645,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[reasoning] - class azure.ai.agentserver.responses.models.ItemFieldToolSearchCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldToolSearchCall(TypedDict): key "arguments": Required[Any] key "call_id": Required[Optional[str]] key "created_by": str @@ -3638,7 +3662,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_call] - class azure.ai.agentserver.responses.models.ItemFieldToolSearchOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldToolSearchOutput(TypedDict): key "call_id": Required[Optional[str]] key "created_by": str key "execution": Required[Literal["server", "client"]] @@ -3655,7 +3679,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_output] - class azure.ai.agentserver.responses.models.ItemFieldWebSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFieldWebSearchToolCall(TypedDict): key "action": Required[Union[WebSearchActionSearch, WebSearchActionOpenPage, WebSearchActionFind]] key "id": Required[str] key "status": Required[Literal["in_progress", "searching", "completed", "incomplete", "failed"]] @@ -3666,7 +3690,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[web_search_call] - class azure.ai.agentserver.responses.models.ItemFileSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFileSearchToolCall(TypedDict): key "id": Required[str] key "queries": Required[list[str]] key "results": Optional[list[FileSearchToolCallResults]] @@ -3679,7 +3703,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[file_search_call] - class azure.ai.agentserver.responses.models.ItemFunctionToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemFunctionToolCall(TypedDict): key "arguments": Required[str] key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -3696,7 +3720,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call] - class azure.ai.agentserver.responses.models.ItemImageGenToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemImageGenToolCall(TypedDict): key "id": Required[str] key "result": Required[Optional[str]] key "status": Required[Literal["in_progress", "completed", "generating", "failed"]] @@ -3707,7 +3731,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[image_generation_call] - class azure.ai.agentserver.responses.models.ItemLocalShellToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemLocalShellToolCall(TypedDict): key "action": Required[LocalShellExecAction] key "call_id": Required[str] key "id": Required[str] @@ -3720,7 +3744,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call] - class azure.ai.agentserver.responses.models.ItemLocalShellToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemLocalShellToolCallOutput(TypedDict): key "id": Required[str] key "output": Required[str] key "status": Optional[Literal["in_progress", "completed", "incomplete"]] @@ -3731,7 +3755,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call_output] - class azure.ai.agentserver.responses.models.ItemMcpApprovalRequest(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemMcpApprovalRequest(TypedDict): key "arguments": Required[str] key "id": Required[str] key "name": Required[str] @@ -3744,7 +3768,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_request] - class azure.ai.agentserver.responses.models.ItemMcpListTools(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemMcpListTools(TypedDict): key "error": ForwardRef('RealtimeMCPError', module='types') key "id": Required[str] key "server_label": Required[str] @@ -3757,7 +3781,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_list_tools] - class azure.ai.agentserver.responses.models.ItemMcpToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemMcpToolCall(TypedDict): key "approval_request_id": Optional[str] key "arguments": Required[str] key "id": Required[str] @@ -3777,7 +3801,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_call] - class azure.ai.agentserver.responses.models.ItemMessage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemMessage(TypedDict): key "content": Required[Union[str, list[MessageContent]]] key "phase": Optional[Literal["commentary", "final_answer"]] key "role": Required[Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]] @@ -3788,7 +3812,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[message] - class azure.ai.agentserver.responses.models.ItemProgram(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemProgram(TypedDict): key "call_id": Required[str] key "code": Required[str] key "fingerprint": Required[str] @@ -3801,7 +3825,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program] - class azure.ai.agentserver.responses.models.ItemProgramOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemProgramOutput(TypedDict): key "call_id": Required[str] key "id": Required[str] key "result": Required[str] @@ -3814,7 +3838,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program_output] - class azure.ai.agentserver.responses.models.ItemReasoningItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemReasoningItem(TypedDict): key "encrypted_content": Optional[str] key "id": Required[str] key "status": Literal["in_progress", "completed", "incomplete"] @@ -3828,14 +3852,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[reasoning] - class azure.ai.agentserver.responses.models.ItemReferenceParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemReferenceParam(TypedDict): key "id": Required[str] key "type": Required[Literal["item_reference"]] id: str type: Literal[item_reference] - class azure.ai.agentserver.responses.models.ItemWebSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ItemWebSearchToolCall(TypedDict): key "action": Required[Union[WebSearchActionSearch, WebSearchActionOpenPage, WebSearchActionFind]] key "id": Required[str] key "status": Required[Literal["in_progress", "searching", "completed", "incomplete", "failed"]] @@ -3846,19 +3870,19 @@ namespace azure.ai.agentserver.responses.models type: Literal[web_search_call] - class azure.ai.agentserver.responses.models.KeyPressAction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.KeyPressAction(TypedDict): key "keys": Required[list[str]] key "type": Required[Literal["keypress"]] keys: list[str] type: Literal[keypress] - class azure.ai.agentserver.responses.models.LocalEnvironmentResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.LocalEnvironmentResource(TypedDict): key "type": Required[Literal["local"]] type: Literal[local] - class azure.ai.agentserver.responses.models.LocalShellExecAction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.LocalShellExecAction(TypedDict): key "command": Required[list[str]] key "env": Required[dict[str, str]] key "timeout_ms": Optional[int] @@ -3873,7 +3897,7 @@ namespace azure.ai.agentserver.responses.models working_directory: str - class azure.ai.agentserver.responses.models.LocalShellToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.LocalShellToolParam(TypedDict): key "description": str key "name": str key "type": Required[Literal["local_shell"]] @@ -3882,7 +3906,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell] - class azure.ai.agentserver.responses.models.LocalSkillParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.LocalSkillParam(TypedDict): key "description": Required[str] key "name": Required[str] key "path": Required[str] @@ -3891,7 +3915,7 @@ namespace azure.ai.agentserver.responses.models path: str - class azure.ai.agentserver.responses.models.LogProb(TypedDict, total=False): + class azure.ai.agentserver.responses.models.LogProb(TypedDict): key "bytes": Required[list[int]] key "logprob": Required[float] key "token": Required[str] @@ -3902,7 +3926,7 @@ namespace azure.ai.agentserver.responses.models top_logprobs: list[TopLogProb] - class azure.ai.agentserver.responses.models.MCPApprovalResponse(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPApprovalResponse(TypedDict): key "approval_request_id": Required[str] key "approve": Required[bool] key "id": Optional[str] @@ -3915,7 +3939,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_response] - class azure.ai.agentserver.responses.models.MCPListToolsTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPListToolsTool(TypedDict): key "annotations": Optional[MCPListToolsToolAnnotations] key "description": Optional[str] key "input_schema": Required[MCPListToolsToolInputSchema] @@ -3926,13 +3950,13 @@ namespace azure.ai.agentserver.responses.models name: str - class azure.ai.agentserver.responses.models.MCPListToolsToolAnnotations(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPListToolsToolAnnotations(TypedDict): - class azure.ai.agentserver.responses.models.MCPListToolsToolInputSchema(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPListToolsToolInputSchema(TypedDict): - class azure.ai.agentserver.responses.models.MCPTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPTool(TypedDict): key "allowed_callers": Optional[list[Literal["direct", "programmatic"]]] key "allowed_tools": Optional[Union[list[str], MCPToolFilter]] key "authorization": str @@ -3961,30 +3985,30 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp] - class azure.ai.agentserver.responses.models.MCPToolFilter(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPToolFilter(TypedDict): key "read_only": bool read_only: bool tool_names: list[str] - class azure.ai.agentserver.responses.models.MCPToolRequireApproval(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MCPToolRequireApproval(TypedDict): key "always": ForwardRef('MCPToolFilter', module='types') key "never": ForwardRef('MCPToolFilter', module='types') always: MCPToolFilter never: MCPToolFilter - class azure.ai.agentserver.responses.models.MemorySearchItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MemorySearchItem(TypedDict): key "memory_item": Required[MemoryItem] memory_item: MemoryItem - class azure.ai.agentserver.responses.models.MemorySearchOptions(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MemorySearchOptions(TypedDict): key "max_memories": int max_memories: int - class azure.ai.agentserver.responses.models.MemorySearchPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MemorySearchPreviewTool(TypedDict): key "description": str key "memory_store_name": Required[str] key "name": str @@ -4001,14 +4025,14 @@ namespace azure.ai.agentserver.responses.models update_delay: int - class azure.ai.agentserver.responses.models.MemorySearchToolCallItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MemorySearchToolCallItemParam(TypedDict): key "results": Optional[list[MemorySearchItem]] key "type": Required[Literal["memory_search_call"]] results: list[MemorySearchItem] type: Literal[memory_search_call] - class azure.ai.agentserver.responses.models.MemorySearchToolCallItemResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MemorySearchToolCallItemResource(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "response_id": str @@ -4023,7 +4047,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[memory_search_call] - class azure.ai.agentserver.responses.models.MessageContentInputFileContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentInputFileContent(TypedDict): key "detail": Literal["auto", "low", "high"] key "file_data": str key "file_id": Optional[str] @@ -4040,7 +4064,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_file] - class azure.ai.agentserver.responses.models.MessageContentInputImageContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentInputImageContent(TypedDict): key "detail": Required[Literal["low", "high", "auto", "original"]] key "file_id": Optional[str] key "image_url": Optional[str] @@ -4053,7 +4077,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_image] - class azure.ai.agentserver.responses.models.MessageContentInputTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentInputTextContent(TypedDict): key "prompt_cache_breakpoint": ForwardRef('PromptCacheBreakpointConfig', module='types') key "text": Required[str] key "type": Required[Literal["input_text"]] @@ -4062,7 +4086,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[input_text] - class azure.ai.agentserver.responses.models.MessageContentOutputTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentOutputTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["output_text"]] annotations: list[Annotation] @@ -4071,24 +4095,24 @@ namespace azure.ai.agentserver.responses.models type: Literal[output_text] - class azure.ai.agentserver.responses.models.MessageContentReasoningTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentReasoningTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["reasoning_text"]] text: str type: Literal[reasoning_text] - class azure.ai.agentserver.responses.models.MessageContentRefusalContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MessageContentRefusalContent(TypedDict): key "refusal": Required[str] key "type": Required[Literal["refusal"]] refusal: str type: Literal[refusal] - class azure.ai.agentserver.responses.models.Metadata(TypedDict, total=False): + class azure.ai.agentserver.responses.models.Metadata(TypedDict): - class azure.ai.agentserver.responses.models.MicrosoftFabricPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MicrosoftFabricPreviewTool(TypedDict): key "description": str key "fabric_dataagent_preview": Required[FabricDataAgentToolParameters] key "name": str @@ -4099,19 +4123,19 @@ namespace azure.ai.agentserver.responses.models type: Literal[fabric_dataagent_preview] - class azure.ai.agentserver.responses.models.Moderation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.Moderation(TypedDict): key "input": Required[ModerationEntry] key "output": Required[ModerationEntry] input: ModerationEntry output: ModerationEntry - class azure.ai.agentserver.responses.models.ModerationConfigParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ModerationConfigParam(TypedDict): key "mode": Required[Literal["score", "block"]] mode: ModerationMode - class azure.ai.agentserver.responses.models.ModerationErrorBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ModerationErrorBody(TypedDict): key "code": Required[str] key "message": Required[str] key "type": Required[Literal["error"]] @@ -4120,21 +4144,21 @@ namespace azure.ai.agentserver.responses.models type: Literal[error] - class azure.ai.agentserver.responses.models.ModerationParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ModerationParam(TypedDict): key "model": Required[str] key "policy": Optional[ModerationPolicyParam] model: str policy: ModerationPolicyParam - class azure.ai.agentserver.responses.models.ModerationPolicyParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ModerationPolicyParam(TypedDict): key "input": Optional[ModerationConfigParam] key "output": Optional[ModerationConfigParam] input: ModerationConfigParam output: ModerationConfigParam - class azure.ai.agentserver.responses.models.ModerationResultBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ModerationResultBody(TypedDict): key "categories": Required[dict[str, bool]] key "category_applied_input_types": Required[dict[str, list[Literal["text", "image"]]]] key "category_scores": Required[dict[str, float]] @@ -4149,7 +4173,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[moderation_result] - class azure.ai.agentserver.responses.models.MoveParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.MoveParam(TypedDict): key "keys": Optional[list[str]] key "type": Required[Literal["move"]] key "x": Required[int] @@ -4160,7 +4184,7 @@ namespace azure.ai.agentserver.responses.models y: int - class azure.ai.agentserver.responses.models.NamespaceToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.NamespaceToolParam(TypedDict): key "description": Required[str] key "name": Required[str] key "tools": Required[list[Union[FunctionToolParam, CustomToolParam]]] @@ -4171,7 +4195,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[namespace] - class azure.ai.agentserver.responses.models.OAuthConsentRequestOutputItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OAuthConsentRequestOutputItem(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "consent_link": Required[str] key "id": Required[str] @@ -4186,12 +4210,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[oauth_consent_request] - class azure.ai.agentserver.responses.models.OpenApiAnonymousAuthDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiAnonymousAuthDetails(TypedDict): key "type": Required[Literal["anonymous"]] type: Literal[anonymous] - class azure.ai.agentserver.responses.models.OpenApiFunctionDefinition(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiFunctionDefinition(TypedDict): key "auth": Required[OpenApiAuthDetails] key "description": str key "name": Required[str] @@ -4204,7 +4228,7 @@ namespace azure.ai.agentserver.responses.models spec: dict[str, Any] - class azure.ai.agentserver.responses.models.OpenApiFunctionDefinitionFunction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiFunctionDefinitionFunction(TypedDict): key "description": str key "name": Required[str] key "parameters": Required[dict[str, Any]] @@ -4213,38 +4237,38 @@ namespace azure.ai.agentserver.responses.models parameters: dict[str, Any] - class azure.ai.agentserver.responses.models.OpenApiManagedAuthDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiManagedAuthDetails(TypedDict): key "security_scheme": Required[OpenApiManagedSecurityScheme] key "type": Required[Literal["managed_identity"]] security_scheme: OpenApiManagedSecurityScheme type: Literal[managed_identity] - class azure.ai.agentserver.responses.models.OpenApiManagedSecurityScheme(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiManagedSecurityScheme(TypedDict): key "audience": Required[str] audience: str - class azure.ai.agentserver.responses.models.OpenApiProjectConnectionAuthDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiProjectConnectionAuthDetails(TypedDict): key "security_scheme": Required[OpenApiProjectConnectionSecurityScheme] key "type": Required[Literal["project_connection"]] security_scheme: OpenApiProjectConnectionSecurityScheme type: Literal[project_connection] - class azure.ai.agentserver.responses.models.OpenApiProjectConnectionSecurityScheme(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiProjectConnectionSecurityScheme(TypedDict): key "project_connection_id": Required[str] project_connection_id: str - class azure.ai.agentserver.responses.models.OpenApiTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiTool(TypedDict): key "openapi": Required[OpenApiFunctionDefinition] key "type": Required[Literal["openapi"]] openapi: OpenApiFunctionDefinition type: Literal[openapi] - class azure.ai.agentserver.responses.models.OpenApiToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -4263,7 +4287,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[openapi_call] - class azure.ai.agentserver.responses.models.OpenApiToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OpenApiToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -4282,7 +4306,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[openapi_call_output] - class azure.ai.agentserver.responses.models.OutputContentOutputTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputContentOutputTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["output_text"]] annotations: list[Annotation] @@ -4291,21 +4315,21 @@ namespace azure.ai.agentserver.responses.models type: Literal[output_text] - class azure.ai.agentserver.responses.models.OutputContentReasoningTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputContentReasoningTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["reasoning_text"]] text: str type: Literal[reasoning_text] - class azure.ai.agentserver.responses.models.OutputContentRefusalContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputContentRefusalContent(TypedDict): key "refusal": Required[str] key "type": Required[Literal["refusal"]] refusal: str type: Literal[refusal] - class azure.ai.agentserver.responses.models.OutputItemAdditionalTools(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemAdditionalTools(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "response_id": str @@ -4320,7 +4344,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[additional_tools] - class azure.ai.agentserver.responses.models.OutputItemApplyPatchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemApplyPatchToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -4339,7 +4363,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call] - class azure.ai.agentserver.responses.models.OutputItemApplyPatchToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemApplyPatchToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -4358,7 +4382,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[apply_patch_call_output] - class azure.ai.agentserver.responses.models.OutputItemCodeInterpreterToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemCodeInterpreterToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "code": Required[Optional[str]] key "container_id": Required[str] @@ -4377,7 +4401,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[code_interpreter_call] - class azure.ai.agentserver.responses.models.OutputItemCompactionBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemCompactionBody(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "encrypted_content": Required[str] key "id": Required[str] @@ -4390,7 +4414,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[compaction] - class azure.ai.agentserver.responses.models.OutputItemComputerToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemComputerToolCall(TypedDict): key "action": ForwardRef('ComputerAction', module='types') key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] @@ -4410,7 +4434,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call] - class azure.ai.agentserver.responses.models.OutputItemComputerToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemComputerToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -4428,7 +4452,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[computer_call_output] - class azure.ai.agentserver.responses.models.OutputItemFileSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemFileSearchToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "queries": Required[list[str]] @@ -4445,7 +4469,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[file_search_call] - class azure.ai.agentserver.responses.models.OutputItemFunctionShellCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemFunctionShellCall(TypedDict): key "action": Required[FunctionShellAction] key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] @@ -4466,7 +4490,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call] - class azure.ai.agentserver.responses.models.OutputItemFunctionShellCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemFunctionShellCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "caller": Optional[ToolCallCaller] @@ -4487,7 +4511,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[shell_call_output] - class azure.ai.agentserver.responses.models.OutputItemFunctionToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemFunctionToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -4510,7 +4534,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call] - class azure.ai.agentserver.responses.models.OutputItemFunctionToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemFunctionToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": str key "caller": Optional[ToolCallCallerParam] @@ -4533,7 +4557,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[function_call_output] - class azure.ai.agentserver.responses.models.OutputItemImageGenToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemImageGenToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "response_id": str @@ -4548,7 +4572,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[image_generation_call] - class azure.ai.agentserver.responses.models.OutputItemLocalShellToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemLocalShellToolCall(TypedDict): key "action": Required[LocalShellExecAction] key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] @@ -4565,7 +4589,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call] - class azure.ai.agentserver.responses.models.OutputItemLocalShellToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemLocalShellToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "output": Required[str] @@ -4580,7 +4604,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[local_shell_call_output] - class azure.ai.agentserver.responses.models.OutputItemMcpApprovalRequest(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemMcpApprovalRequest(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "id": Required[str] @@ -4597,7 +4621,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_request] - class azure.ai.agentserver.responses.models.OutputItemMcpApprovalResponseResource(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemMcpApprovalResponseResource(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "approval_request_id": Required[str] key "approve": Required[bool] @@ -4614,7 +4638,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_approval_response] - class azure.ai.agentserver.responses.models.OutputItemMcpListTools(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemMcpListTools(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "error": ForwardRef('RealtimeMCPError', module='types') key "id": Required[str] @@ -4631,7 +4655,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_list_tools] - class azure.ai.agentserver.responses.models.OutputItemMcpToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemMcpToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "approval_request_id": Optional[str] key "arguments": Required[str] @@ -4655,7 +4679,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp_call] - class azure.ai.agentserver.responses.models.OutputItemMessage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemMessage(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "content": Required[list[MessageContent]] key "id": Required[str] @@ -4674,7 +4698,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[message] - class azure.ai.agentserver.responses.models.OutputItemProgram(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemProgram(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "code": Required[str] @@ -4691,7 +4715,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program] - class azure.ai.agentserver.responses.models.OutputItemProgramOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemProgramOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -4708,7 +4732,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[program_output] - class azure.ai.agentserver.responses.models.OutputItemReasoningItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemReasoningItem(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "encrypted_content": Optional[str] key "id": Required[str] @@ -4726,7 +4750,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[reasoning] - class azure.ai.agentserver.responses.models.OutputItemToolSearchCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemToolSearchCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[Any] key "call_id": Required[Optional[str]] @@ -4747,7 +4771,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_call] - class azure.ai.agentserver.responses.models.OutputItemToolSearchOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemToolSearchOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[Optional[str]] key "created_by": str @@ -4768,7 +4792,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_output] - class azure.ai.agentserver.responses.models.OutputItemWebSearchToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.OutputItemWebSearchToolCall(TypedDict): key "action": Required[Union[WebSearchActionSearch, WebSearchActionOpenPage, WebSearchActionFind]] key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] @@ -4783,26 +4807,26 @@ namespace azure.ai.agentserver.responses.models type: Literal[web_search_call] - class azure.ai.agentserver.responses.models.ProgramToolCallCaller(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ProgramToolCallCaller(TypedDict): key "caller_id": Required[str] key "type": Required[Literal["program"]] caller_id: str type: Literal[program] - class azure.ai.agentserver.responses.models.ProgramToolCallCallerParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ProgramToolCallCallerParam(TypedDict): key "caller_id": Required[str] key "type": Required[Literal["program"]] caller_id: str type: Literal[program] - class azure.ai.agentserver.responses.models.ProgrammaticToolCallingParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ProgrammaticToolCallingParam(TypedDict): key "type": Required[Literal["programmatic_tool_calling"]] type: Literal[programmatic_tool_calling] - class azure.ai.agentserver.responses.models.Prompt(TypedDict, total=False): + class azure.ai.agentserver.responses.models.Prompt(TypedDict): key "id": Required[str] key "variables": Optional[ResponsePromptVariables] key "version": Optional[str] @@ -4811,31 +4835,31 @@ namespace azure.ai.agentserver.responses.models version: str - class azure.ai.agentserver.responses.models.PromptCacheBreakpointConfig(TypedDict, total=False): + class azure.ai.agentserver.responses.models.PromptCacheBreakpointConfig(TypedDict): key "mode": Required[Literal["explicit"]] mode: Literal[explicit] - class azure.ai.agentserver.responses.models.PromptCacheBreakpointParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.PromptCacheBreakpointParam(TypedDict): key "mode": Required[Literal["explicit"]] mode: Literal[explicit] - class azure.ai.agentserver.responses.models.PromptCacheOptions(TypedDict, total=False): + class azure.ai.agentserver.responses.models.PromptCacheOptions(TypedDict): key "mode": Required[Literal["implicit", "explicit"]] key "ttl": Required[Literal["30m"]] mode: PromptCacheModeEnum ttl: PromptCacheTTLEnum - class azure.ai.agentserver.responses.models.PromptCacheOptionsParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.PromptCacheOptionsParam(TypedDict): key "mode": Literal["implicit", "explicit"] key "ttl": Literal["30m"] mode: PromptCacheModeEnum ttl: PromptCacheTTLEnum - class azure.ai.agentserver.responses.models.RankingOptions(TypedDict, total=False): + class azure.ai.agentserver.responses.models.RankingOptions(TypedDict): key "hybrid_search": ForwardRef('HybridSearchOptions', module='types') key "ranker": Literal["auto", "default-2024-11-15"] key "score_threshold": float @@ -4844,7 +4868,7 @@ namespace azure.ai.agentserver.responses.models score_threshold: float - class azure.ai.agentserver.responses.models.RealtimeMCPHTTPError(TypedDict, total=False): + class azure.ai.agentserver.responses.models.RealtimeMCPHTTPError(TypedDict): key "code": Required[int] key "message": Required[str] key "type": Required[Literal["http_error"]] @@ -4853,7 +4877,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[http_error] - class azure.ai.agentserver.responses.models.RealtimeMCPProtocolError(TypedDict, total=False): + class azure.ai.agentserver.responses.models.RealtimeMCPProtocolError(TypedDict): key "code": Required[int] key "message": Required[str] key "type": Required[Literal["protocol_error"]] @@ -4862,14 +4886,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[protocol_error] - class azure.ai.agentserver.responses.models.RealtimeMCPToolExecutionError(TypedDict, total=False): + class azure.ai.agentserver.responses.models.RealtimeMCPToolExecutionError(TypedDict): key "message": Required[str] key "type": Required[Literal["tool_execution_error"]] message: str type: Literal[tool_execution_error] - class azure.ai.agentserver.responses.models.Reasoning(TypedDict, total=False): + class azure.ai.agentserver.responses.models.Reasoning(TypedDict): key "context": Optional[Literal["auto", "current_turn", "all_turns"]] key "effort": Optional[Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]] key "generate_summary": Optional[Literal["auto", "concise", "detailed"]] @@ -4882,14 +4906,14 @@ namespace azure.ai.agentserver.responses.models summary: Literal[auto, concise, detailed] - class azure.ai.agentserver.responses.models.ReasoningTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ReasoningTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["reasoning_text"]] text: str type: Literal[reasoning_text] - class azure.ai.agentserver.responses.models.ResponseAudioDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseAudioDeltaEvent(TypedDict): key "delta": Required[str] key "sequence_number": Required[int] key "type": Required[Literal["delta"]] @@ -4898,14 +4922,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseAudioDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseAudioDoneEvent(TypedDict): key "sequence_number": Required[int] key "type": Required[Literal["done"]] sequence_number: int type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseAudioTranscriptDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseAudioTranscriptDeltaEvent(TypedDict): key "delta": Required[str] key "sequence_number": Required[int] key "type": Required[Literal["delta"]] @@ -4914,14 +4938,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseAudioTranscriptDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseAudioTranscriptDoneEvent(TypedDict): key "sequence_number": Required[int] key "type": Required[Literal["done"]] sequence_number: int type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCodeDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCodeDeltaEvent(TypedDict): key "delta": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -4934,7 +4958,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCodeDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCodeDoneEvent(TypedDict): key "code": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -4947,7 +4971,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -4958,7 +4982,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -4969,7 +4993,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallInterpretingEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCodeInterpreterCallInterpretingEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -4980,7 +5004,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[interpreting] - class azure.ai.agentserver.responses.models.ResponseCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCompletedEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["completed"]] @@ -4989,7 +5013,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseContentPartAddedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseContentPartAddedEvent(TypedDict): key "content_index": Required[int] key "item_id": Required[str] key "output_index": Required[int] @@ -5004,7 +5028,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[added] - class azure.ai.agentserver.responses.models.ResponseContentPartDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseContentPartDoneEvent(TypedDict): key "content_index": Required[int] key "item_id": Required[str] key "output_index": Required[int] @@ -5019,7 +5043,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseCreatedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCreatedEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["created"]] @@ -5028,7 +5052,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[created] - class azure.ai.agentserver.responses.models.ResponseCustomToolCallInputDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCustomToolCallInputDeltaEvent(TypedDict): key "delta": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5041,7 +5065,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseCustomToolCallInputDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseCustomToolCallInputDoneEvent(TypedDict): key "input": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5054,7 +5078,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseErrorEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseErrorEvent(TypedDict): key "code": Required[Optional[str]] key "message": Required[str] key "param": Required[Optional[str]] @@ -5067,14 +5091,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[error] - class azure.ai.agentserver.responses.models.ResponseErrorInfo(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseErrorInfo(TypedDict): key "code": Required[Literal["server_error", "rate_limit_exceeded", "invalid_prompt", "data_residency_mismatch", "bio_policy", "vector_store_timeout", "invalid_image", "invalid_image_format", "invalid_base64_image", "invalid_image_url", "image_too_large", "image_too_small", "image_parse_error", "image_content_policy_violation", "invalid_image_mode", "image_file_too_large", "unsupported_image_media_type", "empty_image_file", "failed_to_download_image", "image_file_not_found"]] key "message": Required[str] code: ResponseErrorCode message: str - class azure.ai.agentserver.responses.models.ResponseFailedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFailedEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["failed"]] @@ -5083,7 +5107,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[failed] - class azure.ai.agentserver.responses.models.ResponseFileSearchCallCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFileSearchCallCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5094,7 +5118,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseFileSearchCallInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFileSearchCallInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5105,7 +5129,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseFileSearchCallSearchingEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFileSearchCallSearchingEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5116,10 +5140,10 @@ namespace azure.ai.agentserver.responses.models type: Literal[searching] - class azure.ai.agentserver.responses.models.ResponseFormatJsonSchemaSchema(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFormatJsonSchemaSchema(TypedDict): - class azure.ai.agentserver.responses.models.ResponseFunctionCallArgumentsDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFunctionCallArgumentsDeltaEvent(TypedDict): key "delta": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5132,7 +5156,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseFunctionCallArgumentsDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseFunctionCallArgumentsDoneEvent(TypedDict): key "arguments": Required[str] key "item_id": Required[str] key "name": Required[str] @@ -5147,7 +5171,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseImageGenCallCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseImageGenCallCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5158,7 +5182,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseImageGenCallGeneratingEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseImageGenCallGeneratingEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5169,7 +5193,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[generating] - class azure.ai.agentserver.responses.models.ResponseImageGenCallInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseImageGenCallInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5180,7 +5204,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseImageGenCallPartialImageEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseImageGenCallPartialImageEvent(TypedDict): key "background": str key "item_id": Required[str] key "output_format": str @@ -5203,7 +5227,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[partial_image] - class azure.ai.agentserver.responses.models.ResponseInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseInProgressEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["in_progress"]] @@ -5212,12 +5236,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseIncompleteDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseIncompleteDetails(TypedDict): key "reason": Literal["max_output_tokens", "content_filter"] reason: Literal[max_output_tokens, content_filter] - class azure.ai.agentserver.responses.models.ResponseIncompleteEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseIncompleteEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["incomplete"]] @@ -5231,7 +5255,7 @@ namespace azure.ai.agentserver.responses.models MAX_OUTPUT_TOKENS = "max_output_tokens" - class azure.ai.agentserver.responses.models.ResponseLogProb(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseLogProb(TypedDict): key "logprob": Required[float] key "token": Required[str] logprob: float @@ -5239,14 +5263,14 @@ namespace azure.ai.agentserver.responses.models top_logprobs: list[ResponseLogProbTopLogprobs] - class azure.ai.agentserver.responses.models.ResponseLogProbTopLogprobs(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseLogProbTopLogprobs(TypedDict): key "logprob": float key "token": str logprob: float token: str - class azure.ai.agentserver.responses.models.ResponseMCPCallArgumentsDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPCallArgumentsDeltaEvent(TypedDict): key "delta": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5259,7 +5283,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseMCPCallArgumentsDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPCallArgumentsDoneEvent(TypedDict): key "arguments": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5272,7 +5296,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseMCPCallCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPCallCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5283,7 +5307,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseMCPCallFailedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPCallFailedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5294,7 +5318,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[failed] - class azure.ai.agentserver.responses.models.ResponseMCPCallInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPCallInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5305,7 +5329,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseMCPListToolsCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPListToolsCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5316,7 +5340,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseMCPListToolsFailedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPListToolsFailedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5327,7 +5351,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[failed] - class azure.ai.agentserver.responses.models.ResponseMCPListToolsInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseMCPListToolsInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5338,7 +5362,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseObject(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseObject(TypedDict): key "agent_reference": Required[Optional[AgentReference]] key "background": Optional[bool] key "completed_at": Optional[int] @@ -5412,7 +5436,7 @@ namespace azure.ai.agentserver.responses.models user: str - class azure.ai.agentserver.responses.models.ResponseOutputItemAddedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseOutputItemAddedEvent(TypedDict): key "item": Required[OutputItem] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5423,7 +5447,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[added] - class azure.ai.agentserver.responses.models.ResponseOutputItemDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseOutputItemDoneEvent(TypedDict): key "item": Required[OutputItem] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5434,7 +5458,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseOutputTextAnnotationAddedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseOutputTextAnnotationAddedEvent(TypedDict): key "annotation": Required[Annotation] key "annotation_index": Required[int] key "content_index": Required[int] @@ -5451,10 +5475,10 @@ namespace azure.ai.agentserver.responses.models type: Literal[added] - class azure.ai.agentserver.responses.models.ResponsePromptVariables(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponsePromptVariables(TypedDict): - class azure.ai.agentserver.responses.models.ResponseQueuedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseQueuedEvent(TypedDict): key "response": Required[ResponseObject] key "sequence_number": Required[int] key "type": Required[Literal["queued"]] @@ -5463,7 +5487,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[queued] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartAddedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartAddedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "part": Required[ResponseReasoningSummaryPartAddedEventPart] @@ -5478,14 +5502,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[added] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartAddedEventPart(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartAddedEventPart(TypedDict): key "text": Required[str] key "type": Required[Literal["summary_text"]] text: str type: Literal[summary_text] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartDoneEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "part": Required[ResponseReasoningSummaryPartDoneEventPart] @@ -5502,14 +5526,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartDoneEventPart(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryPartDoneEventPart(TypedDict): key "text": Required[str] key "type": Required[Literal["summary_text"]] text: str type: Literal[summary_text] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryTextDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryTextDeltaEvent(TypedDict): key "delta": Required[str] key "item_id": Required[str] key "output_index": Required[int] @@ -5524,7 +5548,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseReasoningSummaryTextDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningSummaryTextDoneEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5539,7 +5563,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseReasoningTextDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningTextDeltaEvent(TypedDict): key "content_index": Required[int] key "delta": Required[str] key "item_id": Required[str] @@ -5554,7 +5578,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseReasoningTextDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseReasoningTextDoneEvent(TypedDict): key "content_index": Required[int] key "item_id": Required[str] key "output_index": Required[int] @@ -5569,7 +5593,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseRefusalDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseRefusalDeltaEvent(TypedDict): key "content_index": Required[int] key "delta": Required[str] key "item_id": Required[str] @@ -5584,7 +5608,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseRefusalDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseRefusalDoneEvent(TypedDict): key "content_index": Required[int] key "item_id": Required[str] key "output_index": Required[int] @@ -5599,12 +5623,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseStreamOptions(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseStreamOptions(TypedDict): key "include_obfuscation": bool include_obfuscation: bool - class azure.ai.agentserver.responses.models.ResponseTextDeltaEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseTextDeltaEvent(TypedDict): key "content_index": Required[int] key "delta": Required[str] key "item_id": Required[str] @@ -5621,7 +5645,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[delta] - class azure.ai.agentserver.responses.models.ResponseTextDoneEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseTextDoneEvent(TypedDict): key "content_index": Required[int] key "item_id": Required[str] key "logprobs": Required[list[ResponseLogProb]] @@ -5638,14 +5662,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[done] - class azure.ai.agentserver.responses.models.ResponseTextParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseTextParam(TypedDict): key "format": ForwardRef('TextResponseFormatConfiguration', module='types') key "verbosity": Optional[Literal["low", "medium", "high"]] format: TextResponseFormatConfiguration verbosity: Literal[low, medium, high] - class azure.ai.agentserver.responses.models.ResponseUsage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseUsage(TypedDict): key "input_tokens": Required[int] key "input_tokens_details": Required[ResponseUsageInputTokensDetails] key "output_tokens": Required[int] @@ -5658,19 +5682,19 @@ namespace azure.ai.agentserver.responses.models total_tokens: int - class azure.ai.agentserver.responses.models.ResponseUsageInputTokensDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseUsageInputTokensDetails(TypedDict): key "cache_write_tokens": int key "cached_tokens": Required[int] cache_write_tokens: int cached_tokens: int - class azure.ai.agentserver.responses.models.ResponseUsageOutputTokensDetails(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseUsageOutputTokensDetails(TypedDict): key "reasoning_tokens": Required[int] reasoning_tokens: int - class azure.ai.agentserver.responses.models.ResponseWebSearchCallCompletedEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseWebSearchCallCompletedEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5681,7 +5705,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[completed] - class azure.ai.agentserver.responses.models.ResponseWebSearchCallInProgressEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseWebSearchCallInProgressEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5692,7 +5716,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[in_progress] - class azure.ai.agentserver.responses.models.ResponseWebSearchCallSearchingEvent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ResponseWebSearchCallSearchingEvent(TypedDict): key "item_id": Required[str] key "output_index": Required[int] key "sequence_number": Required[int] @@ -5703,12 +5727,12 @@ namespace azure.ai.agentserver.responses.models type: Literal[searching] - class azure.ai.agentserver.responses.models.ScreenshotParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ScreenshotParam(TypedDict): key "type": Required[Literal["screenshot"]] type: Literal[screenshot] - class azure.ai.agentserver.responses.models.ScrollParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ScrollParam(TypedDict): key "keys": Optional[list[str]] key "scroll_x": Required[int] key "scroll_y": Required[int] @@ -5723,7 +5747,7 @@ namespace azure.ai.agentserver.responses.models y: int - class azure.ai.agentserver.responses.models.SharepointGroundingToolCall(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SharepointGroundingToolCall(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "arguments": Required[str] key "call_id": Required[str] @@ -5740,7 +5764,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[sharepoint_grounding_preview_call] - class azure.ai.agentserver.responses.models.SharepointGroundingToolCallOutput(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SharepointGroundingToolCallOutput(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "call_id": Required[str] key "id": Required[str] @@ -5757,7 +5781,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[sharepoint_grounding_preview_call_output] - class azure.ai.agentserver.responses.models.SharepointGroundingToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SharepointGroundingToolParameters(TypedDict): key "description": str key "name": str description: str @@ -5765,7 +5789,7 @@ namespace azure.ai.agentserver.responses.models project_connections: list[ToolProjectConnection] - class azure.ai.agentserver.responses.models.SharepointPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SharepointPreviewTool(TypedDict): key "description": str key "name": str key "sharepoint_grounding_preview": Required[SharepointGroundingToolParameters] @@ -5776,7 +5800,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[sharepoint_grounding_preview] - class azure.ai.agentserver.responses.models.SkillReferenceParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SkillReferenceParam(TypedDict): key "skill_id": Required[str] key "type": Required[Literal["skill_reference"]] key "version": str @@ -5785,22 +5809,22 @@ namespace azure.ai.agentserver.responses.models version: str - class azure.ai.agentserver.responses.models.SpecificApplyPatchParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SpecificApplyPatchParam(TypedDict): key "type": Required[Literal["apply_patch"]] type: Literal[apply_patch] - class azure.ai.agentserver.responses.models.SpecificFunctionShellParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SpecificFunctionShellParam(TypedDict): key "type": Required[Literal["shell"]] type: Literal[shell] - class azure.ai.agentserver.responses.models.SpecificProgrammaticToolCallingParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SpecificProgrammaticToolCallingParam(TypedDict): key "type": Required[Literal["programmatic_tool_calling"]] type: Literal[programmatic_tool_calling] - class azure.ai.agentserver.responses.models.StructuredOutputDefinition(TypedDict, total=False): + class azure.ai.agentserver.responses.models.StructuredOutputDefinition(TypedDict): key "description": Required[str] key "name": Required[str] key "schema": Required[dict[str, Any]] @@ -5811,7 +5835,7 @@ namespace azure.ai.agentserver.responses.models strict: bool - class azure.ai.agentserver.responses.models.StructuredOutputsOutputItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.StructuredOutputsOutputItem(TypedDict): key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] key "output": Required[Any] @@ -5824,31 +5848,31 @@ namespace azure.ai.agentserver.responses.models type: Literal[structured_outputs] - class azure.ai.agentserver.responses.models.SummaryTextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.SummaryTextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["summary_text"]] text: str type: Literal[summary_text] - class azure.ai.agentserver.responses.models.TextContent(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TextContent(TypedDict): key "text": Required[str] key "type": Required[Literal["text"]] text: str type: Literal[text] - class azure.ai.agentserver.responses.models.TextResponseFormatConfigurationResponseFormatJsonObject(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TextResponseFormatConfigurationResponseFormatJsonObject(TypedDict): key "type": Required[Literal["json_object"]] type: Literal[json_object] - class azure.ai.agentserver.responses.models.TextResponseFormatConfigurationResponseFormatText(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TextResponseFormatConfigurationResponseFormatText(TypedDict): key "type": Required[Literal["text"]] type: Literal[text] - class azure.ai.agentserver.responses.models.TextResponseFormatJsonSchema(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TextResponseFormatJsonSchema(TypedDict): key "description": str key "name": Required[str] key "schema": Required[ResponseFormatJsonSchemaSchema] @@ -5861,7 +5885,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[json_schema] - class azure.ai.agentserver.responses.models.ToolChoiceAllowed(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceAllowed(TypedDict): key "mode": Required[Literal["auto", "required"]] key "tools": Required[list[dict[str, Any]]] key "type": Required[Literal["allowed_tools"]] @@ -5870,51 +5894,51 @@ namespace azure.ai.agentserver.responses.models type: Literal[allowed_tools] - class azure.ai.agentserver.responses.models.ToolChoiceCodeInterpreter(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceCodeInterpreter(TypedDict): key "type": Required[Literal["code_interpreter"]] type: Literal[code_interpreter] - class azure.ai.agentserver.responses.models.ToolChoiceComputer(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceComputer(TypedDict): key "type": Required[Literal["computer"]] type: Literal[computer] - class azure.ai.agentserver.responses.models.ToolChoiceComputerUse(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceComputerUse(TypedDict): key "type": Required[Literal["computer_use"]] type: Literal[computer_use] - class azure.ai.agentserver.responses.models.ToolChoiceComputerUsePreview(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceComputerUsePreview(TypedDict): key "type": Required[Literal["computer_use_preview"]] type: Literal[computer_use_preview] - class azure.ai.agentserver.responses.models.ToolChoiceCustom(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceCustom(TypedDict): key "name": Required[str] key "type": Required[Literal["custom"]] name: str type: Literal[custom] - class azure.ai.agentserver.responses.models.ToolChoiceFileSearch(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceFileSearch(TypedDict): key "type": Required[Literal["file_search"]] type: Literal[file_search] - class azure.ai.agentserver.responses.models.ToolChoiceFunction(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceFunction(TypedDict): key "name": Required[str] key "type": Required[Literal["function"]] name: str type: Literal[function] - class azure.ai.agentserver.responses.models.ToolChoiceImageGeneration(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceImageGeneration(TypedDict): key "type": Required[Literal["image_generation"]] type: Literal[image_generation] - class azure.ai.agentserver.responses.models.ToolChoiceMCP(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceMCP(TypedDict): key "name": Optional[str] key "server_label": Required[str] key "type": Required[Literal["mcp"]] @@ -5923,17 +5947,17 @@ namespace azure.ai.agentserver.responses.models type: Literal[mcp] - class azure.ai.agentserver.responses.models.ToolChoiceWebSearchPreview(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceWebSearchPreview(TypedDict): key "type": Required[Literal["web_search_preview"]] type: Literal[web_search_preview] - class azure.ai.agentserver.responses.models.ToolChoiceWebSearchPreview20250311(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolChoiceWebSearchPreview20250311(TypedDict): key "type": Required[Literal["web_search_preview_2025_03_11"]] type: Literal[web_search_preview_2025_03_11] - class azure.ai.agentserver.responses.models.ToolProjectConnection(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolProjectConnection(TypedDict): key "description": str key "name": str key "project_connection_id": Required[str] @@ -5942,7 +5966,7 @@ namespace azure.ai.agentserver.responses.models project_connection_id: str - class azure.ai.agentserver.responses.models.ToolSearchCallItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolSearchCallItemParam(TypedDict): key "arguments": Required[EmptyModelParam] key "call_id": Optional[str] key "execution": Literal["server", "client"] @@ -5957,7 +5981,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_call] - class azure.ai.agentserver.responses.models.ToolSearchOutputItemParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolSearchOutputItemParam(TypedDict): key "call_id": Optional[str] key "execution": Literal["server", "client"] key "id": Optional[str] @@ -5972,7 +5996,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search_output] - class azure.ai.agentserver.responses.models.ToolSearchToolParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.ToolSearchToolParam(TypedDict): key "description": Optional[str] key "execution": Literal["server", "client"] key "parameters": Optional[EmptyModelParam] @@ -5983,7 +6007,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[tool_search] - class azure.ai.agentserver.responses.models.TopLogProb(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TopLogProb(TypedDict): key "bytes": Required[list[int]] key "logprob": Required[float] key "token": Required[str] @@ -5992,14 +6016,14 @@ namespace azure.ai.agentserver.responses.models token: str - class azure.ai.agentserver.responses.models.TypeParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.TypeParam(TypedDict): key "text": Required[str] key "type": Required[Literal["type"]] text: str type: Literal[type] - class azure.ai.agentserver.responses.models.UrlCitationBody(TypedDict, total=False): + class azure.ai.agentserver.responses.models.UrlCitationBody(TypedDict): key "end_index": Required[int] key "start_index": Required[int] key "title": Required[str] @@ -6012,7 +6036,7 @@ namespace azure.ai.agentserver.responses.models url: str - class azure.ai.agentserver.responses.models.UserProfileMemoryItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.UserProfileMemoryItem(TypedDict): key "content": Required[str] key "kind": Required[Literal["user_profile"]] key "memory_id": Required[str] @@ -6025,15 +6049,15 @@ namespace azure.ai.agentserver.responses.models updated_at: int - class azure.ai.agentserver.responses.models.VectorStoreFileAttributes(TypedDict, total=False): + class azure.ai.agentserver.responses.models.VectorStoreFileAttributes(TypedDict): - class azure.ai.agentserver.responses.models.WaitParam(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WaitParam(TypedDict): key "type": Required[Literal["wait"]] type: Literal[wait] - class azure.ai.agentserver.responses.models.WebSearchActionFind(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchActionFind(TypedDict): key "pattern": Required[str] key "type": Required[Literal["find_in_page"]] key "url": Required[str] @@ -6042,14 +6066,14 @@ namespace azure.ai.agentserver.responses.models url: str - class azure.ai.agentserver.responses.models.WebSearchActionOpenPage(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchActionOpenPage(TypedDict): key "type": Required[Literal["open_page"]] key "url": Optional[str] type: Literal[open_page] url: str - class azure.ai.agentserver.responses.models.WebSearchActionSearch(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchActionSearch(TypedDict): key "query": str key "type": Required[Literal["search"]] queries: list[str] @@ -6058,14 +6082,14 @@ namespace azure.ai.agentserver.responses.models type: Literal[search] - class azure.ai.agentserver.responses.models.WebSearchActionSearchSources(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchActionSearchSources(TypedDict): key "type": Required[Literal["url"]] key "url": Required[str] type: Literal[url] url: str - class azure.ai.agentserver.responses.models.WebSearchApproximateLocation(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchApproximateLocation(TypedDict): key "city": Optional[str] key "country": Optional[str] key "region": Optional[str] @@ -6078,7 +6102,7 @@ namespace azure.ai.agentserver.responses.models type: Literal[approximate] - class azure.ai.agentserver.responses.models.WebSearchConfiguration(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchConfiguration(TypedDict): key "description": str key "instance_name": Required[str] key "name": str @@ -6089,7 +6113,7 @@ namespace azure.ai.agentserver.responses.models project_connection_id: str - class azure.ai.agentserver.responses.models.WebSearchPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchPreviewTool(TypedDict): key "search_context_size": Literal["low", "medium", "high"] key "type": Required[Literal["web_search_preview"]] key "user_location": Optional[ApproximateLocation] @@ -6099,7 +6123,7 @@ namespace azure.ai.agentserver.responses.models user_location: ApproximateLocation - class azure.ai.agentserver.responses.models.WebSearchTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchTool(TypedDict): key "custom_search_configuration": ForwardRef('WebSearchConfiguration', module='types') key "description": str key "external_web_access": bool @@ -6118,24 +6142,24 @@ namespace azure.ai.agentserver.responses.models user_location: WebSearchApproximateLocation - class azure.ai.agentserver.responses.models.WebSearchToolFilters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WebSearchToolFilters(TypedDict): key "allowed_domains": Optional[list[str]] allowed_domains: list[str] - class azure.ai.agentserver.responses.models.WorkIQPreviewTool(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WorkIQPreviewTool(TypedDict): key "type": Required[Literal["work_iq_preview"]] key "work_iq_preview": Required[WorkIQPreviewToolParameters] type: Literal[work_iq_preview] work_iq_preview: WorkIQPreviewToolParameters - class azure.ai.agentserver.responses.models.WorkIQPreviewToolParameters(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WorkIQPreviewToolParameters(TypedDict): key "project_connection_id": Required[str] project_connection_id: str - class azure.ai.agentserver.responses.models.WorkflowActionOutputItem(TypedDict, total=False): + class azure.ai.agentserver.responses.models.WorkflowActionOutputItem(TypedDict): key "action_id": Required[str] key "agent_reference": ForwardRef('AgentReference', module='types') key "id": Required[str] @@ -6682,6 +6706,7 @@ namespace azure.ai.agentserver.responses.streaming *, code: str = "server_error", message: str = "An internal server error occurred.", + metadata: Mapping[str, str] | None = ..., usage: ResponseUsage | None = ... ) -> ResponseFailedEvent: ... diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml index a60e67bac207..296fddc49692 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml @@ -1,4 +1,4 @@ -apiMdSha256: 0ee0cd569289c1c8b416c1f13ffc58299a3d1f1ffe80dcfdd413bcdc3436da6a -packageVersion: 2.3.0 +apiHash: cc1e2b1427d5c59e64c4f95ad587627ca97b39de5dedd7fe00d995239bff14b7 +packageVersion: 2.4.0 parserVersion: 0.3.31 -pythonVersion: 3.11.16 +pythonVersion: 3.11.11 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py deleted file mode 100644 index 19d141fda5ef..000000000000 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_experimental.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. -"""Compatibility shim for the shared Agent Server experimental decorator.""" - -from __future__ import annotations - -import functools -import importlib -import inspect -import logging -import os -import sys -from collections.abc import Callable -from contextvars import ContextVar -from typing import Any, TypeVar, overload - -from typing_extensions import ParamSpec, TypeGuard - -try: - _core_experimental_module: Any | None = importlib.import_module("azure.ai.agentserver.core._experimental") -except ImportError: - _core_experimental_module = None - -P = ParamSpec("P") -T = TypeVar("T") - -if _core_experimental_module is not None: - _core_experimental: Any = _core_experimental_module - DOCSTRING_TEMPLATE = _core_experimental.DOCSTRING_TEMPLATE - DOCSTRING_DEFAULT_INDENTATION = _core_experimental.DOCSTRING_DEFAULT_INDENTATION - EXPERIMENTAL_CLASS_MESSAGE = _core_experimental.EXPERIMENTAL_CLASS_MESSAGE - EXPERIMENTAL_METHOD_MESSAGE = _core_experimental.EXPERIMENTAL_METHOD_MESSAGE - EXPERIMENTAL_LINK_MESSAGE = _core_experimental.EXPERIMENTAL_LINK_MESSAGE - DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = _core_experimental.DISABLE_EXPERIMENTAL_WARNING_ENV_VAR - _warning_cache = getattr(_core_experimental, "_warning_cache") - experimental = _core_experimental.experimental -else: - DOCSTRING_TEMPLATE = ".. note:: {0} {1}\n\n" - DOCSTRING_DEFAULT_INDENTATION = 8 - EXPERIMENTAL_CLASS_MESSAGE = "This is an experimental class," - EXPERIMENTAL_METHOD_MESSAGE = "This is an experimental method," - EXPERIMENTAL_LINK_MESSAGE = ( - "and may change at any time. Please see https://aka.ms/azure-ai-agentserver-experimental " - "for more information." - ) - DISABLE_EXPERIMENTAL_WARNING_ENV_VAR = "AZURE_AI_AGENTSERVER_DISABLE_EXPERIMENTAL_WARNING" - _EXPERIMENTAL_CACHE_KEY_ATTR = "_azure_agentserver_experimental_cache_key" - _EXPERIMENTAL_MESSAGE_ATTR = "_azure_agentserver_experimental_message" - _EXPERIMENTAL_WRAPPED_INIT_ATTR = "_azure_agentserver_experimental_wrapped_init" - - _fallback_warning_cache: set[str] = set() - _experimental_init_active: ContextVar[bool] = ContextVar("experimental_init_active", default=False) - module_logger = logging.getLogger(__name__) - - @overload - def _fallback_experimental(wrapped: type[T]) -> type[T]: ... - - @overload - def _fallback_experimental(wrapped: Callable[P, T]) -> Callable[P, T]: ... - - def _fallback_experimental(wrapped: type[T] | Callable[P, T]) -> type[T] | Callable[P, T]: - def is_class(value: type[T] | Callable[P, T]) -> TypeGuard[type[T]]: - return inspect.isclass(value) - - if is_class(wrapped): - return _add_class_docstring(wrapped) - if inspect.isfunction(wrapped): - return _add_function_docstring(wrapped) - return wrapped - - def _add_class_docstring(cls: type[T]) -> type[T]: - cache_key = f"class:{cls.__module__}.{cls.__qualname__}" - message = f"Class {cls.__module__}.{cls.__qualname__}: {EXPERIMENTAL_CLASS_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" - setattr(cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) - setattr(cls, _EXPERIMENTAL_MESSAGE_ATTR, message) - - doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_CLASS_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) - if cls.__doc__: - cls.__doc__ = _add_note_to_docstring(cls.__doc__, doc_string) - else: - cls.__doc__ = doc_string + ">" - - original_init = cls.__init__ - if "__init__" not in cls.__dict__ or getattr(original_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, False): - return cls - - def wrapped_init(self, *args, **kwargs): # type: ignore[no-untyped-def] - runtime_cls = type(self) - runtime_cache_key = getattr(runtime_cls, _EXPERIMENTAL_CACHE_KEY_ATTR, cache_key) - runtime_message = getattr(runtime_cls, _EXPERIMENTAL_MESSAGE_ATTR, message) - active = _experimental_init_active.get() - if not active and not _should_skip_warning() and not _is_warning_cached(runtime_cache_key): - module_logger.warning(runtime_message) - if active: - return original_init(self, *args, **kwargs) - token = _experimental_init_active.set(True) - try: - return original_init(self, *args, **kwargs) - finally: - _experimental_init_active.reset(token) - - if "__init__" in cls.__dict__ and inspect.isfunction(original_init): - wrapped_init = functools.wraps(original_init)(wrapped_init) - setattr(wrapped_init, _EXPERIMENTAL_WRAPPED_INIT_ATTR, True) - - cls.__init__ = wrapped_init # type: ignore[method-assign] - return cls - - def _add_function_docstring(func: Callable[P, T]) -> Callable[P, T]: - doc_string = DOCSTRING_TEMPLATE.format(EXPERIMENTAL_METHOD_MESSAGE, EXPERIMENTAL_LINK_MESSAGE) - if func.__doc__: - func.__doc__ = _add_note_to_docstring(func.__doc__, doc_string) - else: - func.__doc__ = doc_string + ">" - - @functools.wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: - cache_key = f"function:{func.__module__}.{func.__qualname__}" - message = ( - f"Method {func.__module__}.{func.__qualname__}: " - f"{EXPERIMENTAL_METHOD_MESSAGE} {EXPERIMENTAL_LINK_MESSAGE}" - ) - if not _should_skip_warning() and not _is_warning_cached(cache_key): - module_logger.warning(message) - return func(*args, **kwargs) - - return wrapped - - def _add_note_to_docstring(doc_string: str, note: str) -> str: - indent = _get_indentation_size(doc_string) - doc_string = doc_string.rjust(len(doc_string) + indent) - return note + doc_string - - def _get_indentation_size(doc_string: str) -> int: - lines = doc_string.expandtabs().splitlines() - indent = sys.maxsize - for line in lines[1:]: - stripped = line.lstrip() - if stripped: - indent = min(indent, len(line) - len(stripped)) - return indent if indent < sys.maxsize else DOCSTRING_DEFAULT_INDENTATION - - def _should_skip_warning() -> bool: - return os.getenv(DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, "false").lower() == "true" - - def _is_warning_cached(cache_key: str) -> bool: - if cache_key in _fallback_warning_cache: - return True - _fallback_warning_cache.add(cache_key) - return False - - _warning_cache = _fallback_warning_cache - experimental = _fallback_experimental diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py index c54fdbc040fa..ebb7e0c8d758 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py @@ -15,6 +15,8 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, NoReturn, Sequence, cast +from azure.ai.agentserver.core import FoundryOriginalSubject + from .models._helpers import get_input_expanded, to_item, to_output_item from .models.runtime import ResponseModeFlags @@ -84,7 +86,14 @@ class PlatformContext: ``is None`` to detect whether the header was present at all. """ - def __init__(self, *, user_id_key: str | None = None, call_id: str | None = None) -> None: + def __init__( + self, + *, + user_id_key: str | None = None, + call_id: str | None = None, + original_subject: FoundryOriginalSubject | None = None, + ) -> None: + self.original_subject = original_subject self.user_id_key = user_id_key """Partition key for per-user state (from ``x-agent-user-id``). ``None`` when the header was not sent.""" @@ -109,7 +118,13 @@ def _history_ids_key( def _snapshot_platform_context(context: PlatformContext | None) -> PlatformContext | None: - return None if context is None else PlatformContext(user_id_key=context.user_id_key, call_id=context.call_id) + return ( + None + if context is None + else PlatformContext( + user_id_key=context.user_id_key, call_id=context.call_id, original_subject=context.original_subject + ) + ) class _HistoryIdsEntry: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py index 7049c489522a..275da0509b18 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py @@ -4,4 +4,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "2.3.0" +VERSION = "2.5.0b1" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py index ba85f7d5eabe..6eefd6588570 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py @@ -16,7 +16,7 @@ import logging import os import threading -from typing import TYPE_CHECKING, Any, AsyncGenerator, cast +from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, cast from anyio import CancelScope from opentelemetry import baggage as _otel_baggage @@ -27,6 +27,7 @@ from azure.ai.agentserver.core import ( # pylint: disable=import-error,no-name-in-module FoundryAgentRequestContext, + FoundryOriginalSubject, flush_spans, flush_spans_async, reset_request_context, @@ -45,9 +46,11 @@ ) from azure.ai.agentserver.core import read_request_id from azure.ai.agentserver.core.streaming import ( # pylint: disable=import-error,no-name-in-module + EventStream, EventStreamNotFoundError, streams, ) +from azure.ai.agentserver.core.streaming._concrete import FileBackedReplayEventStream from ..models import _generated as _generated_models @@ -74,7 +77,7 @@ extract_request_id, start_create_span, ) -from ._orchestrator import _HandlerError, _refresh_background_status, _ResponseOrchestrator +from ._orchestrator import _HandlerError, _refresh_background_status, _ResponseOrchestrator, _ServerShuttingDown from ._request_parsing import ( _apply_item_cursors, _extract_agent_identity, @@ -85,6 +88,8 @@ _resolve_session_id, ) from ._runtime_state import _RuntimeState +from ._response_task_lifecycle import _fence_response_tasks, _response_tasks +from ._task_id import derive_lifecycle_id from ._validation import ( ERROR_SOURCE_PLATFORM, ERROR_SOURCE_UPSTREAM, @@ -131,9 +136,12 @@ def __init__( interval_seconds: float | None, *, headers: dict[str, str], + on_finalize: Callable[[], Awaitable[None]] | None = None, ) -> None: self._source = source self._stream = cast(AsyncGenerator[str, None], with_keep_alive(source, interval_seconds)) + self._on_finalize = on_finalize + self._finalized = False super().__init__(self._stream, media_type="text/event-stream", headers=headers) async def stream_response(self, send: Send) -> None: @@ -142,13 +150,11 @@ async def stream_response(self, send: Send) -> None: :param send: The ASGI ``send`` callable for the response. :type send: ~starlette.types.Send """ - finalized = False async def finalize() -> None: - nonlocal finalized - if finalized: + if self._finalized: return - finalized = True + self._finalized = True # Starlette's older ASGI path cancels this task's AnyIO scope on # disconnect. Finish iterator cleanup before flushing ended spans. with CancelScope(shield=True): @@ -158,7 +164,11 @@ async def finalize() -> None: try: await self._source.aclose() finally: - await _flush_spans_for_mode(os.environ.get(_FLUSH_MODE_ENV, _DEFAULT_FLUSH_MODE)) + try: + if self._on_finalize is not None: + await self._on_finalize() + finally: + await _flush_spans_for_mode(os.environ.get(_FLUSH_MODE_ENV, _DEFAULT_FLUSH_MODE)) async def send_with_flush(message: Message) -> None: if message["type"] == "http.response.body" and not message.get("more_body", False): @@ -643,6 +653,7 @@ def _create_response_context( platform_context=PlatformContext( user_id_key=ctx.user_id, call_id=ctx.call_id, + original_subject=FoundryOriginalSubject.from_headers(request.headers), ), prefetched_history_ids=ctx.prefetched_history_ids, steerable=self._runtime_options.steerable_conversations, @@ -740,6 +751,151 @@ async def _prefetch_history_ids( span.end(exc) return _error_response(exc, _hdrs) + async def _reserve_response_id( + self, + response_id: str, + user_id_key: str | None, + *, + call_id: str | None = None, + publication_context: ResponseContext | None = None, + ) -> bool: + """Reserve an ID only when neither execution nor replay state retains it. + + :param response_id: The response identifier to reserve. + :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :keyword call_id: The originating call identity forwarded to storage. + :paramtype call_id: str | None + :keyword publication_context: The fresh request's exact runtime publication owner. + :paramtype publication_context: ResponseContext | None + :return: Whether the caller acquired the reservation. + :rtype: bool + """ + if not await self._runtime_state.reserve(response_id, user_id_key, publication_context=publication_context): + if await self._runtime_state.is_draining(): + raise _ServerShuttingDown("Server is shutting down.") + return False + available = False + try: + try: + replay = await streams.get(derive_lifecycle_id(response_id, user_id_key)) + except EventStreamNotFoundError: + available = True + else: + available = await self._reclaim_empty_replay( + response_id, PlatformContext(user_id_key=user_id_key, call_id=call_id), replay + ) + if available and await self._runtime_state.is_draining(): + available = False + raise _ServerShuttingDown("Server is shutting down.") + finally: + if not available: + with CancelScope(shield=True): + await self._runtime_state.release_reservation(response_id, user_id_key) + return available + + def _execution_error_response( + self, + ctx: _ExecutionContext, + error: _HandlerError | _ServerShuttingDown | TaskConflictError, + agent_session_id: str | None, + ) -> JSONResponse: + """Map rejected admission or handler failure to its HTTP response. + + :param ctx: The create request's execution context. + :type ctx: _ExecutionContext + :param error: A shutdown rejection, conversation lock conflict, or developer-handler failure. + :type error: _HandlerError | _ServerShuttingDown | TaskConflictError + :param agent_session_id: The response's session identifier. + :type agent_session_id: str | None + :return: The existing shutdown, conflict, or handler-error response. + :rtype: JSONResponse + """ + headers = self._session_headers(agent_session_id) + if isinstance(error, _ServerShuttingDown): + ctx.span.end(None) + return _service_unavailable("Server is shutting down.", headers) + if isinstance(error, _HandlerError): + logger.error("Handler error in create (response_id=%s)", ctx.response_id, exc_info=error.original) + return JSONResponse( + { + "error": { + "message": "internal server error", + "type": "server_error", + "code": "server_error", + "param": None, + } + }, + status_code=500, + headers=_apply_error_source_headers(headers, ERROR_SOURCE_UPSTREAM), + ) + # Only current_status is public; never expose the internal chain identity. + logger.info( + "Conversation lock conflict for %s: task is %s", + ctx.response_id, + error.current_status, + ) + return JSONResponse( + { + "error": { + "message": f"Conversation is locked \u2014 task is {error.current_status}", + "type": "conflict", + "code": "conversation_locked", + "param": None, + } + }, + status_code=409, + headers=headers, + ) + + async def _reclaim_empty_replay(self, response_id: str, context: PlatformContext, replay: EventStream) -> bool: + """Reclaim a proven ownerless empty file, including unfinished cleanup. + + :param response_id: The public response identifier. + :type response_id: str + :param context: The caller's authenticated user and call identity. + :type context: PlatformContext + :param replay: The existing replay returned by noncreating lookup. + :type replay: EventStream + :return: Whether the abandoned replay was removed. + :rtype: bool + """ + if not isinstance(replay, FileBackedReplayEventStream): + return False + cleanup_started = False + try: + if await replay.last_cursor() is not None: + return False + except EventStreamNotFoundError: + cleanup_started = True + try: + if replay._path.stat().st_size != 0: # pylint: disable=protected-access + return False + except FileNotFoundError: + if not cleanup_started: + raise + try: + await self._provider.get_response(response_id, context=context) + except (FoundryResourceNotFoundError, KeyError): + if await _response_tasks(response_id, context.user_id_key, self._response_task_names()): + return False + await streams.delete(derive_lifecycle_id(response_id, context.user_id_key)) + return True + return False + + def _response_task_names(self) -> tuple[str, str]: + """Return the durable response primitives registered by this host. + + :return: The one-shot and multi-turn task names. + :rtype: tuple[str, str] + """ + resilient = self._orchestrator._resilient_orchestrator # pylint: disable=protected-access + return ( + resilient.task_fn.name, + resilient._multi_turn_task_fn.name, # pylint: disable=protected-access + ) + # ------------------------------------------------------------------ # Route handlers # ------------------------------------------------------------------ @@ -798,23 +954,31 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= config_session_id = getattr(getattr(self._host, "config", None), "session_id", "") or "" host_config = getattr(self._host, "config", None) config_session_guid = ( - getattr(host_config, "session_guid", "") or "" - if getattr(host_config, "is_hosted", False) - else "" + getattr(host_config, "session_guid", "") or "" if getattr(host_config, "is_hosted", False) else "" ) agent_session_id = _resolve_session_id( parsed, payload, env_session_id=config_session_id, agent_reference=agent_reference ) - ctx = self._build_execution_context( - parsed=parsed, - response_id=response_id, - agent_reference=agent_reference, - agent_session_id=agent_session_id, - agent_session_guid=config_session_guid or None, - span=span, - request=request, - ) + try: + ctx = self._build_execution_context( + parsed=parsed, + response_id=response_id, + agent_reference=agent_reference, + agent_session_id=agent_session_id, + agent_session_guid=config_session_guid or None, + span=span, + request=request, + ) + except ValueError as exc: + logger.warning("Invalid platform request context", exc_info=True) + span.end(exc) + return _error_response( + exc, + self._session_headers(agent_session_id), + request_id=scope_request_id, + error_source=ERROR_SOURCE_PLATFORM, + ) logger.info( "Creating response %s: streaming=%s background=%s store=%s model=%s " @@ -868,12 +1032,37 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= call_id=ctx.call_id or None, user_id=ctx.user_id or None, session_id=agent_session_id, + original_subject=( + ctx.context.platform_context.original_subject if ctx.context is not None else None + ), ) platform_ctx_token = set_request_context(platform_context) disconnect_task: asyncio.Task[None] | None = None stream_owns_flush = False + stream_owns_reservation = False + reservation_acquired = False try: + if not await self._reserve_response_id( + ctx.response_id, ctx.user_id, call_id=ctx.call_id, publication_context=ctx.context + ): + span.end(None) + return JSONResponse( + { + "error": { + "message": ( + "An active execution or retained replay stream with this response ID already exists." + ), + "type": "conflict", + "code": "response_id_conflict", + "param": "response_id", + } + }, + status_code=409, + headers=self._session_headers(agent_session_id), + ) + reservation_acquired = True + if ctx.stream: raw_iter = cast(AsyncGenerator[str, None], self._orchestrator.run_stream(ctx)) @@ -915,7 +1104,14 @@ async def _iter_with_context(): # type: ignore[return] raise finally: reset_request_context(stream_ctx_token) + + async def _finalize_request_stream() -> None: + # Unlike a generator's finally, response finalization also + # runs when sending headers fails before the first iteration. + try: await _stop_disconnect_monitor(disconnect_task, ctx.cancellation_signal) + finally: + await self._runtime_state.release_reservation(ctx.response_id, ctx.user_id) sse_response = _CreateStreamingResponse( _iter_with_context(), @@ -923,8 +1119,10 @@ async def _iter_with_context(): # type: ignore[return] # pump would advance its handler ahead of HTTP sends. self._runtime_options.sse_keep_alive_interval_seconds if ctx.store else None, headers={**self._sse_headers, **self._session_headers(agent_session_id)}, + on_finalize=_finalize_request_stream, ) stream_owns_flush = True + stream_owns_reservation = True return sse_response if not ctx.background: @@ -944,28 +1142,6 @@ async def _iter_with_context(): # type: ignore[return] status_code=200, headers=self._session_headers(agent_session_id), ) - except _HandlerError as exc: - logger.error( - "Handler error in sync create (response_id=%s)", - ctx.response_id, - exc_info=exc.original, - ) - # Handler errors are server-side faults, not client errors - err_body = { - "error": { - "message": "internal server error", - "type": "server_error", - "code": "server_error", - "param": None, - } - } - return JSONResponse( - err_body, - status_code=500, - headers=_apply_error_source_headers( - self._session_headers(agent_session_id), ERROR_SOURCE_UPSTREAM - ), - ) finally: await _stop_disconnect_monitor(disconnect_task, ctx.cancellation_signal) @@ -989,7 +1165,7 @@ async def _iter_with_context(): # type: ignore[return] ctx.response_id, exc.actual_last_input_id, ) - err_body = { + err_body: dict[str, dict[str, str | None]] = { "error": { "message": ( "This agent does not support conversation forking. " @@ -1002,42 +1178,8 @@ async def _iter_with_context(): # type: ignore[return] } } return JSONResponse(err_body, status_code=409, headers=self._session_headers(agent_session_id)) - except TaskConflictError as exc: - # Spec 023 — under the spec-022 narrow surface, TaskConflictError - # carries only ``current_status``; the task_id is not part of - # the public exception API. The endpoint already knows the - # response_id (logged separately); the chain identity is not - # exposed to the client error body. - logger.info( - "Conversation lock conflict for %s: task is %s", - ctx.response_id, - exc.current_status, - ) - err_body = { - "error": { - "message": f"Conversation is locked — task is {exc.current_status}", - "type": "conflict", - "code": "conversation_locked", - "param": None, - } - } - return JSONResponse(err_body, status_code=409, headers=self._session_headers(agent_session_id)) - except _HandlerError as exc: - logger.error("Handler error in create (response_id=%s)", ctx.response_id, exc_info=exc.original) - # Handler errors are server-side faults, not client errors - err_body = { - "error": { - "message": "internal server error", - "type": "server_error", - "code": "server_error", - "param": None, - } - } - return JSONResponse( - err_body, - status_code=500, - headers=_apply_error_source_headers(self._session_headers(agent_session_id), ERROR_SOURCE_UPSTREAM), - ) + except (_HandlerError, _ServerShuttingDown, TaskConflictError) as exc: + return self._execution_error_response(ctx, exc, agent_session_id) except Exception as exc: # pylint: disable=broad-exception-caught if is_platform_error(exc): # A resilient task-start (or other platform-infrastructure) @@ -1065,6 +1207,9 @@ async def _iter_with_context(): # type: ignore[return] logger.error("Unexpected error in create (response_id=%s)", ctx.response_id, exc_info=exc) raise finally: + if reservation_acquired and not stream_owns_reservation: + with CancelScope(shield=True): + await self._runtime_state.release_reservation(ctx.response_id, ctx.user_id) _response_id_var.reset(rid_token) _conversation_id_var.reset(cid_token) _streaming_var.reset(str_token) @@ -1118,8 +1263,8 @@ async def handle_get(self, request: Request) -> Response: # pylint: disable=too _context.user_id_key is not None, _context.call_id is not None, ) - record = await self._runtime_state.get(response_id) - if record is None: + record = await self._runtime_state.get(response_id, _context.user_id_key) + if record is None or await self._runtime_state.is_retained_for_deletion(response_id, _context.user_id_key): return await self._handle_get_fallback( request, response_id, @@ -1216,7 +1361,7 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements,to :return: Response. :rtype: Response """ - if await self._runtime_state.is_deleted(response_id): + if await self._runtime_state.is_deleted(response_id, _context.user_id_key): return _deleted_response(response_id, _hdrs) if not stream_replay: @@ -1270,16 +1415,17 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements,to _hdrs, param="stream", ) - except FoundryResourceNotFoundError: - # Response doesn't exist — fall through to the no-stream - # branches below which handle 404 cleanly. - pass - except Exception: # pylint: disable=broad-exception-caught - logger.debug( - "Background pre-check failed for SSE replay (response_id=%s); " + "proceeding to stream lookup", + except (FoundryResourceNotFoundError, KeyError): + return _not_found(response_id, _hdrs) + except FoundryBadRequestError as exc: + return _invalid_request(str(exc), _hdrs, param="response_id") + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error( + "Provider validation failed for SSE replay (response_id=%s)", response_id, exc_info=True, ) + return _error_response(exc, _hdrs) # Stream provider fallback: replay persisted SSE events when runtime state is gone. replay_response = await self._try_replay_persisted_stream( @@ -1393,7 +1539,7 @@ async def _stream_from_subject(): # registry. The orchestrator populates ``record.subject`` # on the bg+stream path but older eviction-race conditions # may leave it unset; the registry lookup is idempotent. - stream = await streams.get_or_create(record.response_id) + stream = await streams.get_or_create(derive_lifecycle_id(record.response_id, record.user_id_key)) async for event in stream.subscribe(after=_cursor): yield encode_sse_any_event(event) @@ -1422,29 +1568,21 @@ async def _try_replay_persisted_stream( :type request: Request :param response_id: The response identifier to replay. :type response_id: str - :keyword context: Unused (kept for call-site compatibility — the stream - registry is process-wide; partitioning is handled by the response - provider, not the stream backing). + :keyword context: Platform context selecting the user's replay partition. :paramtype context: PlatformContext | None :keyword headers: Optional extra headers (e.g. session headers) to merge with SSE headers. :paramtype headers: dict[str, str] | None :return: A streaming replay response, an error response, or ``None``. :rtype: Response | None """ - del context # unused — see docstring parsed_cursor = self._parse_starting_after(request, headers) if isinstance(parsed_cursor, Response): return parsed_cursor - # Look up an existing stream — do NOT mint one. If the id was - # never registered (e.g. ``store=false`` responses never produce - # a replay log) ``get`` raises NotFound and we return ``None`` - # so the caller falls through to its 404 path. Auto-evicted - # streams (TTL expiry on a closed file-backed log that was - # never re-opened) also surface as NotFound here because the - # tombstone was never installed for them. + # Restore retained replay after restart without creating an absent log. + # Missing or expired logs fall through to the caller's no-stream path. try: - stream = await streams.get(response_id) + stream = await streams.get(derive_lifecycle_id(response_id, context.user_id_key if context else None)) except EventStreamNotFoundError: return None # Peek at a method that raises NotFound for already-destroyed @@ -1497,17 +1635,39 @@ async def handle_delete(self, request: Request) -> Response: return format_error _context = _extract_platform_context(request) + if not await self._runtime_state.begin_deletion(response_id, _context.user_id_key): + return _not_found(response_id, _hdrs) + try: + return await self._handle_reserved_delete(response_id, _context, _hdrs) + finally: + with CancelScope(shield=True): + await self._runtime_state.end_deletion(response_id, _context.user_id_key) + + async def _handle_reserved_delete( + self, response_id: str, _context: PlatformContext, _hdrs: dict[str, str] + ) -> Response: + """Delete while holding the caller-scoped lifecycle reservation. + + :param response_id: The response identifier to delete. + :type response_id: str + :param _context: The authenticated caller context. + :type _context: PlatformContext + :param _hdrs: Session response headers. + :type _hdrs: dict[str, str] + :return: Deletion confirmation or error. + :rtype: Response + """ logger.info( "Deleting response %s, has_user_id=%s has_call_id=%s", response_id, _context.user_id_key is not None, _context.call_id is not None, ) - record = await self._runtime_state.get(response_id) + record = await self._runtime_state.get(response_id, _context.user_id_key) if record is None: # Provider fallback: response may have been evicted from memory after # reaching terminal state, or the server restarted since creation. - if await self._runtime_state.is_deleted(response_id): + if await self._runtime_state.is_deleted(response_id, _context.user_id_key): return _not_found(response_id, _hdrs) result = await self._provider_delete_response(response_id, _context, _hdrs) @@ -1533,7 +1693,11 @@ async def handle_delete(self, request: Request) -> Response: if not record.visible_via_get and not record.mode_flags.background: return _not_found(response_id, _hdrs) - if record.mode_flags.background and record.status in {"queued", "in_progress"}: + if ( + record.mode_flags.background + and record.status in {"queued", "in_progress"} + and not await self._runtime_state.is_retained_for_deletion(response_id, _context.user_id_key) + ): return _invalid_request( "Cannot delete an in-flight response.", _hdrs, @@ -1562,42 +1726,50 @@ async def handle_delete(self, request: Request) -> Response: "Response execution failed before DELETE response_id=%s", response_id, exc_info=error ) - deleted = await self._runtime_state.delete(response_id) - if not deleted: - # Race: the background task's eager eviction (try_evict) removed - # the record between our get() and delete() calls. Eviction for - # terminal responses typically happens after a provider - # persistence attempt, but persistence is best-effort and may not - # have succeeded, so delegate to the provider path as a fallback. - if record.mode_flags.store: - result = await self._provider_delete_response(response_id, _context, _hdrs) - if result is not None: - return result - return _not_found(response_id, _hdrs) + return await self._delete_owned_response(record, _context, _hdrs) - if record.mode_flags.store: - try: - await self._provider.delete_response(response_id, context=_extract_platform_context(request)) - except Exception: # pylint: disable=broad-exception-caught - logger.warning("Best-effort provider delete failed for response_id=%s", response_id, exc_info=True) - # Tear down the per-response stream — frees the registry slot, - # installs the deletion tombstone (so subsequent GET ?stream=true - # raises Gone, mapped to 404 below), and removes the on-disk log - # for the file-backed backing. - try: - await streams.delete(response_id) - except Exception: # pylint: disable=broad-exception-caught - logger.debug( - "Best-effort stream delete failed for response_id=%s", - response_id, - exc_info=True, - ) + async def _delete_owned_response( + self, record: ResponseExecution, context: PlatformContext, headers: dict[str, str] + ) -> Response: + """Delete backing state before compare-deleting the retained ownership. + :param record: The exact authorized execution or provider snapshot. + :type record: ResponseExecution + :param context: The caller's authenticated platform context. + :type context: PlatformContext + :param headers: Session response headers. + :type headers: dict[str, str] + :return: Deletion confirmation or a retryable cleanup error. + :rtype: Response + """ + response_id = record.response_id + if not await self._runtime_state.retain_for_deletion(record): + return _invalid_request("Response execution changed. Retry deletion.", headers) + try: + await _fence_response_tasks( + response_id, + context.user_id_key, + self._response_task_names(), + durable_required=self._runtime_options.resilient_background, + ) + await streams.delete(derive_lifecycle_id(response_id, context.user_id_key)) + try: + await self._provider.delete_response(response_id, context=context) + except (FoundryResourceNotFoundError, KeyError): + pass # Idempotent retry after the provider already removed the envelope. + deleted = False + with CancelScope(shield=True): + deleted = await self._runtime_state.delete(response_id, context.user_id_key, expected_record=record) + if not deleted: + return _invalid_request("Response execution changed. Retry deletion.", headers) + except FoundryBadRequestError as exc: + return _invalid_request(str(exc), headers, param="response_id") + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error("Response delete failed for response_id=%s", response_id, exc_info=True) + return _error_response(exc, headers) logger.info("Deleted response %s", response_id) return JSONResponse( - {"id": response_id, "object": "response", "deleted": True}, - status_code=200, - headers=_hdrs, + {"id": response_id, "object": "response", "deleted": True}, status_code=200, headers=headers ) async def _provider_delete_response( @@ -1608,14 +1780,13 @@ async def _provider_delete_response( ) -> Response | None: """Delete a response from the resilient provider (storage). - Used by :meth:`handle_delete` in both the provider-fallback path - (record already evicted from memory) and the eviction-race recovery - path (record evicted between ``get()`` and ``delete()``). + Used by :meth:`handle_delete` after eviction or restart. The authorized + snapshot is retained as exact cleanup ownership until deletion succeeds. Returns a :class:`Response` on success or on a deterministic error (bad request, API error). Returns ``None`` when the provider - reports the response as not found **or** when an unexpected error - occurs (logged at DEBUG), so the caller can fall through to 404. + reports the response as not found. Unexpected errors are logged and + returned as errors, preserving ownership for a cleanup retry. :param response_id: The response ID to delete. :type response_id: str @@ -1627,25 +1798,9 @@ async def _provider_delete_response( :rtype: Response | None """ try: - await self._provider.delete_response(response_id, context=context) - # Tear down the per-response stream — same as the in-memory - # delete path above. - try: - await streams.delete(response_id) - except Exception: # pylint: disable=broad-exception-caught - logger.debug( - "Best-effort stream delete failed for response_id=%s", - response_id, - exc_info=True, - ) - # Mark as deleted in runtime state so subsequent requests get 404 - await self._runtime_state.mark_deleted(response_id) - logger.info("Deleted response %s via provider", response_id) - return JSONResponse( - {"id": response_id, "object": "response", "deleted": True}, - status_code=200, - headers=headers, - ) + # Authorize before touching replay, and retain provider ownership + # until backing cleanup succeeds so a failed DELETE can be retried. + snapshot = await self._provider.get_response(response_id, context=context) except (FoundryResourceNotFoundError, KeyError): return None # Caller falls through to 404 except FoundryBadRequestError as exc: @@ -1653,13 +1808,22 @@ async def _provider_delete_response( except FoundryApiError as exc: logger.error("Storage API error for DELETE response_id=%s: %s", response_id, exc, exc_info=True) return _error_response(exc, headers) - except Exception: # pylint: disable=broad-exception-caught - logger.debug( + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error( "Provider fallback failed for DELETE response_id=%s", response_id, exc_info=True, ) - return None + return _error_response(exc, headers) + record = ResponseExecution( + response_id=response_id, + user_id_key=context.user_id_key, + mode_flags=ResponseModeFlags(stream=True, store=True, background=bool(snapshot.get("background"))), + status=snapshot.get("status") or "completed", + response=snapshot, + response_created_seen=True, + ) + return await self._delete_owned_response(record, context, headers) async def handle_cancel(self, request: Request) -> Response: """Route handler for ``POST /responses/{response_id}/cancel``. @@ -1682,7 +1846,7 @@ async def handle_cancel(self, request: Request) -> Response: _context.user_id_key is not None, _context.call_id is not None, ) - record = await self._runtime_state.get(response_id) + record = await self._runtime_state.get(response_id, _context.user_id_key) if record is None: return await self._handle_cancel_fallback(response_id, _context, _hdrs) @@ -1760,7 +1924,7 @@ async def handle_cancel(self, request: Request) -> Response: snapshot = _RuntimeState.to_snapshot(record) # Eager eviction: free memory now that the terminal state is persisted - await self._runtime_state.try_evict(record.response_id) + await self._runtime_state.try_evict(record.response_id, record.user_id_key) logger.info("Cancelled response %s, status=%s", response_id, snapshot.get("status")) return JSONResponse(strip_internal_metadata(snapshot), status_code=200, headers=_hdrs) @@ -1857,7 +2021,7 @@ async def handle_input_items(self, request: Request) -> Response: # User isolation enforcement for in-flight responses. After eviction, # the provider (Foundry storage) enforces partitioning server-side. - record = await self._runtime_state.get(response_id) + record = await self._runtime_state.get(response_id, _context.user_id_key) if record is not None: if not _RuntimeState.check_user_isolation(record.user_id_key, _context.user_id_key): return _not_found(response_id, _hdrs) @@ -1893,7 +2057,7 @@ async def handle_input_items(self, request: Request) -> Response: # Fall back to runtime_state for in-flight responses not yet persisted to provider. # User isolation was already checked above when the record is in-flight. try: - items = await self._runtime_state.get_input_items(response_id) + items = await self._runtime_state.get_input_items(response_id, _context.user_id_key) except ValueError: return _deleted_response(response_id, _hdrs) except KeyError: @@ -1966,6 +2130,9 @@ async def handle_shutdown(self) -> None: # semantically distinct surfaces and handlers expect # different responses to each. record.response_context.shutdown.set() + task_context = record.response_context._task_context # pylint: disable=protected-access + if task_context is not None: + task_context.shutdown.set() record.cancel_signal.set() diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_observability.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_observability.py index a5fcf942df68..57e8af3791c3 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_observability.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_observability.py @@ -157,6 +157,7 @@ def build_create_span_tags( tags: dict[str, Any] = { "service.name": _SERVICE_NAME, "gen_ai.provider.name": _PROVIDER_NAME, + "microsoft.foundry": _MICROSOFT_FOUNDRY_VALUE, "gen_ai.system": "responses", "gen_ai.operation.name": "invoke_agent", "gen_ai.response.id": ctx.response_id, @@ -181,6 +182,7 @@ def build_create_span_tags( _SERVICE_NAME = "azure.ai.agentserver" _PROVIDER_NAME = "AzureAI Hosted Agents" +_MICROSOFT_FOUNDRY_VALUE = "True" _MAX_REQUEST_ID_LEN = 256 @@ -197,6 +199,7 @@ def _initial_create_span_tags() -> dict[str, Any]: return { "service.name": _SERVICE_NAME, "gen_ai.provider.name": _PROVIDER_NAME, + "microsoft.foundry": _MICROSOFT_FOUNDRY_VALUE, "gen_ai.system": "responses", "gen_ai.operation.name": "invoke_agent", } @@ -256,6 +259,7 @@ def build_create_otel_attrs( attrs: dict[str, Any] = { "gen_ai.response.id": ctx.response_id, "gen_ai.provider.name": _PROVIDER_NAME, + "microsoft.foundry": _MICROSOFT_FOUNDRY_VALUE, "service.name": _SERVICE_NAME, "gen_ai.operation.name": "invoke_agent", "gen_ai.request.model": ctx.model or "", diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py index 0ba5b73558d1..ff5af7fa99b9 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py @@ -14,9 +14,10 @@ import asyncio # pylint: disable=do-not-import-asyncio import json import logging +import os from copy import deepcopy -from contextlib import aclosing -from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, cast +from contextlib import aclosing, asynccontextmanager +from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, Awaitable, Callable, NoReturn, cast import anyio @@ -71,6 +72,7 @@ from ._execution_context import _ExecutionContext from ._dispatch import decide_disposition from ._runtime_state import _RuntimeState +from ._task_id import derive_lifecycle_id if TYPE_CHECKING: from .._response_context import ResponseContext @@ -78,6 +80,20 @@ logger = logging.getLogger("azure.ai.agentserver") +_MEMORY_SNAPSHOT_ENV = "FOUNDRY_MEMORY_SNAPSHOT_ENABLED" + + +def _task_activation_deferred() -> bool: + """Return whether Core defers task startup until session activation. + + Core defers enabled tasks only when the memory snapshot variable is + explicitly ``true``. An absent or ``false`` value uses ordinary startup. + + :return: True when a missing task runtime can mean activation is pending. + :rtype: bool + """ + return os.environ.get(_MEMORY_SNAPSHOT_ENV, "").strip().lower() == "true" + _STORAGE_ERROR_MESSAGE = ( "An internal error occurred while storing the response. " @@ -125,20 +141,29 @@ async def _iter_handler_with_request_context( user_id=platform_context.user_id_key, call_id=platform_context.call_id, session_id=agent_session_id, + original_subject=platform_context.original_subject, ) handler_iterator = create_fn(parsed, context, cancellation_signal) - while True: + try: + while True: + token = set_request_context(request_context) if request_context is not None else None + try: + try: + event = await handler_iterator.__anext__() + except StopAsyncIteration: + return + finally: + if token is not None: + reset_request_context(token) + yield event + finally: token = set_request_context(request_context) if request_context is not None else None try: - try: - event = await handler_iterator.__anext__() - except StopAsyncIteration: - return + await _close_iterator(handler_iterator) finally: if token is not None: reset_request_context(token) - yield event async def _resolve_input_items_for_persistence( @@ -1163,6 +1188,7 @@ async def _run_background_non_stream( :return: None :rtype: None """ + record.execution_task = asyncio.current_task() record.transition_to("in_progress") st = _BgRunState() try: @@ -1235,7 +1261,7 @@ async def _run_background_non_stream( # Eager eviction: free memory once terminal (or store=False). Skip when # persistence failed — the in-memory record is the only GET source. if runtime_state is not None and record.is_terminal and not record.persistence_failed: - await runtime_state.try_evict(response_id) + await runtime_state.try_evict(response_id, record.user_id_key) def _refresh_background_status(record: ResponseExecution) -> None: @@ -1280,6 +1306,10 @@ def __init__(self, original: BaseException) -> None: super().__init__(str(original)) +class _ServerShuttingDown(RuntimeError): + """Fresh execution was rejected because shutdown closed admission.""" + + def _make_ephemeral_record(ctx: "_ExecutionContext", state: "_PipelineState") -> "ResponseExecution": """Create a transient ResponseExecution for non-bg streams needing persistence. @@ -1308,6 +1338,7 @@ def _make_ephemeral_record(ctx: "_ExecutionContext", state: "_PipelineState") -> status="in_progress", input_items=deepcopy(ctx.input_items), previous_response_id=ctx.previous_response_id, + response_context=ctx.context, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, user_id_key=ctx.user_id, @@ -1995,7 +2026,7 @@ async def _emit_pending_terminal_to_stream(self, ctx: _ExecutionContext, state: # ctx.store`` because only Row 1 used the wire_stream pattern; # unified Row 2/3 stream now also subscribe to wire_stream and need # the terminal emit. - _term_stream = await streams.get_or_create(ctx.response_id) + _term_stream = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_emit(_term_stream, state.pending_terminal) async def _resolve_emit_and_defer_terminal_persist( @@ -2017,7 +2048,8 @@ async def _resolve_emit_and_defer_terminal_persist( """ response_payload = await self._prepare_terminal_resolution(ctx, state, record) if response_payload is not None: - await self._runtime_state.add(record) + if not await self._runtime_state.add(record): + return await self._emit_pending_terminal_to_stream(ctx, state) if response_payload is None or not (ctx.store and record.response is not None): return @@ -2027,7 +2059,7 @@ async def _deferred_terminal_persist() -> None: # in runtime_state AFTER this resolution but BEFORE the deferred # persist runs, so stamp the failure on whatever record GET will # actually serve (fall back to the captured record if absent). - target = await self._runtime_state.get(ctx.response_id) or record + target = await self._runtime_state.get(ctx.response_id, ctx.user_id) or record await self._persist_terminal_io( ctx, state, @@ -2055,9 +2087,9 @@ async def _drain_deferred_terminal_persist(self, ctx: _ExecutionContext, state: await persist() state.deferred_terminal_persist = None state.defer_evict = False - record = await self._runtime_state.get(ctx.response_id) + record = await self._runtime_state.get(ctx.response_id, ctx.user_id) if record is not None and record.is_terminal and not record.persistence_failed: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) async def _persist_and_resolve_terminal( self, ctx: _ExecutionContext, state: _PipelineState, record: ResponseExecution @@ -2162,7 +2194,7 @@ async def _register_bg_execution( # guarantees the same instance for the same id, so any other caller # that does ``streams.get_or_create(response_id)`` for this id sees # the same fan-out target. - execution.subject = await streams.get_or_create(ctx.response_id) + execution.subject = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) state.bg_record = execution assert state.bg_record.subject is not None # Attach the draining task (set by the in-process store=True fallback) @@ -2172,7 +2204,8 @@ async def _register_bg_execution( # for the resilient body, which tracks its own task separately. if state.execution_task is not None: execution.execution_task = state.execution_task - await self._runtime_state.add(execution) + if not await self._runtime_state.add(execution): + raise _HandlerError(RuntimeError("Response lifecycle ownership changed before publication.")) if ctx.store: _context = ctx.context.platform_context if ctx.context else None _initial_response_obj = cast("generated_models.ResponseObject", initial_payload) @@ -2332,8 +2365,9 @@ async def _emit_standalone_error( *, message: str = "An internal server error occurred.", code: str | None = None, + publish_to_stream: bool = True, ) -> generated_models.ResponseStreamEvent: - """Build a standalone ``error`` event and emit it to the wire stream. + """Build a standalone ``error`` event and optionally publish it to the wire stream. Shared by the pre-creation error paths (B8 / B30 / first-event-contract): each constructs the same ``error`` event shape and, for store+stream @@ -2346,6 +2380,8 @@ async def _emit_standalone_error( :paramtype message: str :keyword code: The optional error code. :paramtype code: str | None + :keyword publish_to_stream: Whether to publish to the response's registry stream. + :paramtype publish_to_stream: bool :returns: The constructed ``error`` event. :rtype: generated_models.ResponseStreamEvent """ @@ -2358,8 +2394,8 @@ async def _emit_standalone_error( "sequence_number": 0, } ) - if ctx.store and ctx.stream: - _err_stream = await streams.get_or_create(ctx.response_id) + if publish_to_stream and ctx.store and ctx.stream: + _err_stream = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_emit(_err_stream, event) return event @@ -2415,7 +2451,7 @@ async def _acquire_first_event( # yet — bind the per-response stream so the wire iterator sees the # fallback events. Skip terminal (the caller emits the resolved one). if ctx.store and (ctx.background or ctx.stream) and event.get("type") not in self._TERMINAL_SSE_TYPES: - _fallback_stream = await streams.get_or_create(ctx.response_id) + _fallback_stream = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_emit(_fallback_stream, event) if event.get("type") in self._TERMINAL_SSE_TYPES: state.pending_terminal = event @@ -2639,7 +2675,7 @@ async def _register_and_handle_storage_failure( error_code="storage_error", error_message=_STORAGE_ERROR_MESSAGE, ) - _wire_stream = await streams.get_or_create(ctx.response_id) + _wire_stream = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_emit(_wire_stream, first_normalized) evs.append(first_normalized) # Build, validate, and APPEND the terminal BEFORE emitting it so a @@ -2658,7 +2694,7 @@ async def _register_and_handle_storage_failure( evs.append(failed_normalized) return True, evs # Bg+stream: standalone error event (no response.created). - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) error_event = construct_event_model( { "type": "error", @@ -2668,7 +2704,7 @@ async def _register_and_handle_storage_failure( "sequence_number": 0, } ) - _err_stream = await streams.get_or_create(ctx.response_id) + _err_stream = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_emit(_err_stream, error_event) evs.append(error_event) return True, evs @@ -2919,7 +2955,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # ``GET ?stream=true`` correctly reports "no stream available". if record.status == "cancelled": try: - await streams.delete(ctx.response_id) + await streams.delete(derive_lifecycle_id(ctx.response_id, ctx.user_id)) except Exception: # pylint: disable=broad-exception-caught logger.debug( "Cancelled stream cleanup failed (response_id=%s)", @@ -2936,7 +2972,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # Skip eviction when persistence failed — the in-memory record is # the only remaining source of truth for GET. if record.is_terminal and not record.persistence_failed and not state.defer_evict: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id, expected_record=record) return # --- Path B: No pre-existing record --- @@ -3030,7 +3066,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # for them, so no stream is needed. replay_subject: EventStream | None = None if ctx.store and ctx.background: - replay_subject = await streams.get_or_create(ctx.response_id) + replay_subject = await streams.get_or_create(derive_lifecycle_id(ctx.response_id, ctx.user_id)) await self._safe_close(replay_subject) execution = ResponseExecution( @@ -3041,6 +3077,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) input_items=deepcopy(ctx.input_items), previous_response_id=ctx.previous_response_id, cancel_signal=ctx.cancellation_signal if ctx.background else None, + response_context=ctx.context, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, user_id_key=ctx.user_id, @@ -3058,7 +3095,9 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # the deferred terminal provider write is still running. if state.execution_task is not None: execution.execution_task = state.execution_task - await self._runtime_state.add(execution) + if not await self._runtime_state.add(execution): + ctx.span.end(state.captured_error) + return ctx.span.end(state.captured_error) @@ -3066,7 +3105,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # Skip eviction when persistence failed — the in-memory record is the # only remaining source of truth for GET. if execution.is_terminal and not execution.persistence_failed and not state.defer_evict: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id, expected_record=execution) # ------------------------------------------------------------------ # Public execution methods @@ -3208,7 +3247,13 @@ async def _live_stream(self, ctx: _ExecutionContext) -> AsyncIterator[str]: # p # same instance for the same id, so the resilient body's # ``_register_bg_execution`` gets back this exact stream — every # emit fans out to the wire iterator below. - wire_stream = await streams.get_or_create(ctx.response_id) + lifecycle_id = derive_lifecycle_id(ctx.response_id, ctx.user_id) + stream_created = False + try: + wire_stream = await streams.get(lifecycle_id) + except EventStreamNotFoundError: + wire_stream = await streams.get_or_create(lifecycle_id) + stream_created = True async def _resilient_stream_fallback() -> None: # In-process fallback if ``_start_resilient_background`` cannot @@ -3244,7 +3289,7 @@ async def _resilient_stream_fallback() -> None: # runtime_state record so a later GET (and the deferred # persistence-failure stamping) observes the same object # the GET read-through serves. - r = await self._runtime_state.get(ctx.response_id) or state.bg_record + r = await self._runtime_state.get(ctx.response_id, ctx.user_id) or state.bg_record if r is None: # No canonical record was registered (e.g. the # handler produced a terminal without a create @@ -3254,7 +3299,8 @@ async def _resilient_stream_fallback() -> None: # window serves it (not 404) and any stamped # persistence failure is reachable. r = _make_ephemeral_record(ctx, state) - await self._runtime_state.add(r) + if not await self._runtime_state.add(r): + return r.execution_task = state.execution_task await self._resolve_emit_and_defer_terminal_persist(ctx, state, r) finally: @@ -3263,7 +3309,9 @@ async def _resilient_stream_fallback() -> None: await self._safe_close(wire_stream) await self._drain_deferred_terminal_persist(ctx, state) finally: - await self._runtime_state.discard_pending(ctx.response_id) + await self._runtime_state.discard_pending( + ctx.response_id, ctx.user_id, expected_record=start_record + ) # Minimal record only for ``_start_resilient_background``'s parameter # shape. The fallback tracks it as unpublished shutdown work until @@ -3290,8 +3338,12 @@ async def _resilient_stream_fallback() -> None: assert request_task is not None start_record.execution_task = request_task if not await self._runtime_state.add_pending(start_record): - yield encode_sse_any_event(await self._emit_standalone_error(ctx, code="server_error")) - await self._safe_close(wire_stream) + with anyio.CancelScope(shield=True): + if stream_created and await self._runtime_state.get(ctx.response_id, ctx.user_id) is None: + await streams.delete(lifecycle_id) + yield encode_sse_any_event( + await self._emit_standalone_error(ctx, code="server_error", publish_to_stream=False) + ) return try: @@ -3301,12 +3353,20 @@ async def _resilient_stream_fallback() -> None: _resilient_stream_fallback, disposition=_unified_disposition, ) - except asyncio.CancelledError: - await self._runtime_state.discard_pending(ctx.response_id) - raise - except Exception as exc: # pylint: disable=broad-exception-caught - await self._runtime_state.discard_pending(ctx.response_id) - if not getattr(exc, PLATFORM_ERROR_TAG, False): + except (Exception, asyncio.CancelledError) as exc: # pylint: disable=broad-exception-caught + # The HTTP response still owns the create reservation. Finish + # rejected admission cleanup before it can release that identity. + with anyio.CancelScope(shield=True): + await self._runtime_state.discard_pending( + ctx.response_id, ctx.user_id, expected_record=start_record + ) + if ( + stream_created + and getattr(start_record, "resilient_task_run", None) is None + and start_record.execution_task is request_task + ): + await streams.delete(lifecycle_id) + if not isinstance(exc, Exception) or not getattr(exc, PLATFORM_ERROR_TAG, False): # 409 conflicts (TaskConflictError / LastInputIdPreconditionFailed) # and any non-platform error propagate unchanged. raise @@ -3315,10 +3375,12 @@ async def _resilient_stream_fallback() -> None: # is impossible; surface the failure the streaming-native way — # a standalone ``error`` SSE event (the same B8 pre-creation # contract used for every other streaming creation failure) — - # then close the wire stream. The platform error is already - # logged + tagged by ``_start_resilient_background``. - yield encode_sse_any_event(await self._emit_standalone_error(ctx, code="server_error")) - await self._safe_close(wire_stream) + # on this HTTP connection only. Never recreate deleted replay + # or close a stream owned by an existing/started execution. + # The platform error is already logged + tagged by startup. + yield encode_sse_any_event( + await self._emit_standalone_error(ctx, code="server_error", publish_to_stream=False) + ) return # Relay the resilient wire stream to this client, interleaving @@ -3331,10 +3393,17 @@ async def _resilient_stream_fallback() -> None: # --- Ephemeral (non-stored) responses: no resilient task --- # The request owns this producer even without keep-alives. Keeping handler # iteration in one task lets cleanup finish outside the ASGI cancel scope. - handler_iterator = self._create_fn(ctx.parsed, ctx.context, ctx.cancellation_signal) - async with aclosing(self._live_stream_keep_alive(ctx, state, handler_iterator)) as ephemeral_stream: - async for chunk in ephemeral_stream: - yield chunk + try: + async with self._track_ephemeral_execution(ctx) as pending: + state.execution_task = pending.execution_task + handler_iterator = self._create_fn(ctx.parsed, ctx.context, ctx.cancellation_signal) + async with aclosing(self._live_stream_keep_alive(ctx, state, handler_iterator)) as ephemeral_stream: + async for chunk in ephemeral_stream: + yield chunk + except _ServerShuttingDown: + yield encode_sse_any_event( + await self._emit_standalone_error(ctx, code="server_error", publish_to_stream=False) + ) async def _live_stream_keep_alive( # pylint: disable=too-many-statements self, @@ -3477,7 +3546,7 @@ async def _await_sync_resilient_terminal(self, ctx: _ExecutionContext, record: R task_exc, exc_info=True, ) - elif execution_task is not None: + elif execution_task is not None and execution_task is not asyncio.current_task(): try: await execution_task except asyncio.CancelledError: # pylint: disable=try-except-raise @@ -3510,7 +3579,7 @@ async def _await_sync_resilient_terminal(self, ctx: _ExecutionContext, record: R # Try to remove the record so GET returns 404. Best-effort; the # record may already be evicted. try: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) except Exception: # pylint: disable=broad-exception-caught pass ctx.span.end(None) @@ -3582,7 +3651,7 @@ async def _resolve_sync_client_disconnect( ctx.response_id, ) try: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) except Exception: # pylint: disable=broad-exception-caught pass ctx.span.end(None) @@ -3624,7 +3693,9 @@ async def run_sync(self, ctx: _ExecutionContext) -> dict[str, Any]: if not ctx.store: # No store ⇒ no resilient task possible. Run handler inline; the # response is ephemeral (not retrievable via GET). - return await self._run_sync_inner(ctx, state) + async with self._track_ephemeral_execution(ctx) as pending: + state.execution_task = pending.execution_task + return await self._run_sync_inner(ctx, state) # (Spec 024 Phase 2 — bookkeeping unification) Row 3 unified path: # handler runs inside the resilient task body, HTTP request awaits the @@ -3645,7 +3716,9 @@ async def run_sync(self, ctx: _ExecutionContext) -> dict[str, Any]: initial_model=ctx.model, initial_agent_reference=ctx.agent_reference, ) - await self._runtime_state.add(record) + record.execution_task = asyncio.current_task() + if not await self._runtime_state.add(record): + await self._raise_publication_rejection() async def _runner() -> None: """Fallback runner if _start_resilient_background's resilient start fails. @@ -3775,6 +3848,42 @@ async def _runner() -> None: ctx.span.end(None) return _RuntimeState.to_snapshot(record) + async def _raise_publication_rejection(self) -> NoReturn: + """Surface shutdown admission separately from lifecycle conflicts.""" + if await self._runtime_state.is_draining(): + raise _ServerShuttingDown("Server is shutting down.") + raise _HandlerError(RuntimeError("Response lifecycle ownership changed before publication.")) + + @asynccontextmanager + async def _track_ephemeral_execution(self, ctx: _ExecutionContext) -> AsyncIterator[ResponseExecution]: + """Track non-stored handler work before its first invocation. + + :param ctx: The admitted create request. + :type ctx: _ExecutionContext + :return: The exact pending execution included in shutdown snapshots. + :rtype: AsyncIterator[ResponseExecution] + """ + record = ResponseExecution( + response_id=ctx.response_id, + mode_flags=ResponseModeFlags(stream=ctx.stream, store=False, background=ctx.background), + status="in_progress", + response_context=ctx.context, + cancel_signal=ctx.cancellation_signal, + agent_session_id=ctx.agent_session_id, + conversation_id=ctx.conversation_id, + user_id_key=ctx.user_id, + initial_model=ctx.model, + initial_agent_reference=ctx.agent_reference, + ) + record.execution_task = asyncio.current_task() + if not await self._runtime_state.add_pending(record): + await self._raise_publication_rejection() + try: + yield record + finally: + with anyio.CancelScope(shield=True): + await self._runtime_state.discard_pending(ctx.response_id, ctx.user_id, expected_record=record) + async def _run_sync_inner(self, ctx: _ExecutionContext, state: _PipelineState) -> dict[str, Any]: """Inner body of :meth:`run_sync` — extracted so the bookkeeping task can be signalled in a ``try/finally`` wrapper in the caller. @@ -3839,11 +3948,13 @@ async def _run_sync_inner(self, ctx: _ExecutionContext, state: _PipelineState) - user_id_key=ctx.user_id, ) record.set_response_snapshot(cast("generated_models.ResponseObject", response_payload)) + record.execution_task = state.execution_task # Always register in runtime state so that cancel/GET can find the record # and return the correct status code (e.g., 400 for non-bg cancel). # Always register so cancel/GET can find this record. - await self._runtime_state.add(record) + if not await self._runtime_state.add(record): + raise _HandlerError(RuntimeError("Response lifecycle ownership changed before publication.")) if ctx.store: # Persist via provider (non-bg sync: single create at terminal state). @@ -3899,13 +4010,13 @@ async def _run_sync_inner(self, ctx: _ExecutionContext, state: _PipelineState) - # Skip eviction when persistence failed — sync failures are handled below # where we evict before raising HTTP 500. if record.is_terminal and not record.persistence_failed: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) # §3.1: For sync mode, persistence failure surfaces as HTTP 500. # The client never receives a response_id on 500, so evict the record # to avoid unbounded memory growth during storage outages. if record.persistence_failed: - await self._runtime_state.try_evict(ctx.response_id) + await self._runtime_state.try_evict(ctx.response_id, ctx.user_id) ctx.span.end(record.persistence_exception) raise _HandlerError( record.persistence_exception or RuntimeError("Persistence failed") @@ -3947,7 +4058,9 @@ async def run_background(self, ctx: _ExecutionContext) -> dict[str, Any]: ) # Register so GET can observe in-flight state - await self._runtime_state.add(record) + record.execution_task = asyncio.current_task() + if not await self._runtime_state.add(record): + await self._raise_publication_rejection() # Launch handler immediately (S-003: handler runs asynchronously) # Use anyio.CancelScope(shield=True) + suppress CancelledError so the @@ -4145,12 +4258,14 @@ async def _run_resilient_stream_body( ) state = _PipelineState() + state.execution_task = asyncio.current_task() + record.execution_task = state.execution_task # The wire iterator on _live_stream's side subscribed to the # per-response stream BEFORE this body started. Looking it up from # the registry returns the SAME instance — every emit fans out to # the wire iterator. Bind it on ``record`` so the helpers that read # ``record.subject`` (publish, close) target this stream. - wire_stream = await streams.get_or_create(response_id) + wire_stream = await streams.get_or_create(derive_lifecycle_id(response_id, ctx.user_id)) record.subject = wire_stream # Seed the per-attempt sequence counter from the prior persisted # event count. On fresh entry the persisted log is empty → @@ -4166,8 +4281,8 @@ async def _run_resilient_stream_body( except EventStreamNotFoundError: # The previous run completed AND every persisted event has # since expired. Start fresh. - await streams.delete(response_id) - wire_stream = await streams.get_or_create(response_id) + await streams.delete(derive_lifecycle_id(response_id, ctx.user_id)) + wire_stream = await streams.get_or_create(derive_lifecycle_id(response_id, ctx.user_id)) record.subject = wire_stream state.next_seq = 0 except Exception: # pylint: disable=broad-exception-caught @@ -4261,14 +4376,19 @@ async def _start_resilient_background( - **No manager installed** — the start raises :class:`~azure.ai.agentserver.core.tasks.TaskManagerNotInitialized`. - Because ``AgentServerHost`` fails the lifespan when resilient tasks are - ENABLED but construction/startup fails, a missing manager in a running - deployment means resilient tasks are DISABLED (opt-out) — durability - was not requested (this also covers in-process test clients whose - lifespan never ran). The signal is **swallowed** and the handler runs - in-process via ``fallback_runner`` — the response still executes and - persists (GET works); it is simply not crash-recoverable. NOT a - failure. + When ``FOUNDRY_MEMORY_SNAPSHOT_ENABLED`` is absent or ``false`` (cold startup), + ``AgentServerHost`` fails the lifespan when resilient tasks are + ENABLED but construction/startup fails, so a missing manager in a + running deployment means resilient tasks are DISABLED (opt-out) — + durability was not requested (this also covers in-process test clients + whose lifespan never ran). The signal is **swallowed** and the handler + runs in-process via ``fallback_runner`` — the response still executes + and persists (GET works); it is simply not crash-recoverable. NOT a + failure. When the variable is ``true``, startup is deferred + to the after-restore hook, so a missing manager with resilient tasks + ENABLED means session activation is pending or failed; the error is + tagged as a platform infrastructure error and re-raised instead of + running the handler non-durably. - **Subsystem present but the start fails**: fail immediately. The exception is tagged as a platform infrastructure error and re-raised (no silent degradation to a non-durable task — that would hide a real @@ -4293,8 +4413,13 @@ async def _start_resilient_background( tagged with ``PLATFORM_ERROR_TAG`` so the endpoint surfaces ``x-platform-error-source: platform``. A missing subsystem (``TaskManagerNotInitialized``) is NOT raised — it is swallowed and - handled via the in-process fallback (see above). + handled via the in-process fallback — unless resilient tasks are + enabled and activation is deferred (see above). """ + if not (self._runtime_options.resilient_background or self._runtime_options.steerable_conversations): + record.execution_task = asyncio.create_task(fallback_runner()) + return + from ._resilient_orchestrator import ( ResilientResponseOrchestrator, ) # pylint: disable=import-outside-toplevel @@ -4342,20 +4467,21 @@ async def _start_resilient_background( # `previous_response_id`. Propagate so the endpoint layer # surfaces HTTP 409 `conversation_fork_not_supported`. raise - except TaskManagerNotInitialized: - # No manager is installed. Because ``AgentServerHost`` fails the - # lifespan when resilient tasks are ENABLED but construction/startup - # fails, a missing manager in a running deployment means resilient - # tasks are simply DISABLED (opt-out) — recovery/durability was not - # requested. (It also covers in-process test clients whose lifespan - # never ran.) SWALLOW and run the handler in-process: the response - # still executes and persists (GET works), it is simply not - # crash-recoverable. This is the deliberate non-durable path, NOT a - # failure. + except TaskManagerNotInitialized as exc: + if _task_activation_deferred(): + logger.error( + "Task runtime is not initialized for enabled resilient response %s", + ctx.response_id, + ) + setattr(exc, PLATFORM_ERROR_TAG, True) + await self._runtime_state.delete(ctx.response_id, ctx.user_id, expected_record=record) + raise + # Without deferred activation, a missing runtime means resilient + # tasks are disabled or the lifespan never ran (in-process clients). logger.info( "Resilient task subsystem not enabled for response %s; running handler " - "in-process (non-durable). Enable via set_resilient_tasks_enabled(True) " - "(or resilient_background) for crash recovery.", + "in-process (non-durable). Run the host lifespan to initialize " + "the requested Responses task runtime.", ctx.response_id, ) record.execution_task = asyncio.create_task(fallback_runner()) @@ -4376,5 +4502,5 @@ async def _start_resilient_background( # Best-effort cleanup of the in-flight record so a later GET does not # observe a phantom ``in_progress`` response (no-op for callers that # never registered the record, e.g. the streaming path). - await self._runtime_state.delete(ctx.response_id) + await self._runtime_state.delete(ctx.response_id, ctx.user_id) raise diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_input.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_input.py index 46cef981cd39..8bcf86007eec 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_input.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_input.py @@ -49,6 +49,39 @@ _K_CALL_ID = "call_id" _K_CLIENT_HEADERS = "client_headers" _K_QUERY_PARAMETERS = "query_parameters" +_K_INCARNATION_ID = "response_incarnation_id" + + +def user_id_key_from_params(params: dict[str, Any]) -> str | None: + """Validate the persisted partition before constructing any identity keys. + + :param params: The persisted resilient boundary. + :type params: dict[str, Any] + :return: The string partition, including an empty string, or legacy None. + :rtype: str | None + :raises ValueError: If a present partition is neither a string nor None. + """ + value = params.get(_K_USER_ID_KEY) + if value is not None and not isinstance(value, str): + raise ValueError("Invalid durable response user_id_key") + return value + + +def incarnation_from_params(params: dict[str, Any]) -> str | None: + """Read a private durable incarnation without generating one on recovery. + + :param params: The persisted resilient boundary. + :type params: dict[str, Any] + :return: The incarnation identifier, or None for a legacy input. + :rtype: str | None + :raises ValueError: If an incarnation field is malformed. + """ + if _K_INCARNATION_ID not in params: + return None + value = params[_K_INCARNATION_ID] + if not isinstance(value, str) or len(value) != 32 or any(char not in "0123456789abcdef" for char in value): + raise ValueError("Invalid durable response incarnation") + return value def platform_context_from_params(params: dict[str, Any]) -> PlatformContext: @@ -76,9 +109,10 @@ def platform_context_from_params(params: dict[str, Any]) -> PlatformContext: :type params: dict[str, Any] :returns: The platform context. :rtype: PlatformContext + :raises ValueError: If the persisted user partition is malformed. """ return PlatformContext( - user_id_key=params.get(_K_USER_ID_KEY), + user_id_key=user_id_key_from_params(params), call_id=params.get(_K_CALL_ID), ) @@ -135,7 +169,7 @@ class RuntimeRefs: """Process-local object references for an in-flight resilient response. These cannot be JSON-serialized for cross-process recovery, so they are kept - in a process-local cache keyed by ``response_id`` and are **never** part of + in a process-local cache keyed by caller-scoped response incarnation and are **never** part of :class:`ResilientResponseInput`. On same-process re-entry the task body reads them from the cache; on cross-process recovery the cache entry is absent and the body rebuilds state from the persisted :class:`ResilientResponseInput`. @@ -177,20 +211,44 @@ def __init__( call_id: str | None = None, client_headers: dict[str, str] | None = None, query_parameters: dict[str, str] | None = None, + incarnation_id: str | None = None, ) -> None: self.request = request - self.response_id = response_id + self._identity = ( + response_id, + None if incarnation_id is None else incarnation_from_params({_K_INCARNATION_ID: incarnation_id}), + ) self.disposition = disposition # Normalized to a plain dict at construction so the object is always # serialization-safe (no leaked ``AgentReference`` model). self.agent_reference: dict[str, Any] = _normalize_agent_reference(agent_reference) self.agent_session_id = agent_session_id self.agent_session_guid = agent_session_guid - self.user_id_key = user_id_key + self.user_id_key = user_id_key_from_params({_K_USER_ID_KEY: user_id_key}) self.call_id = call_id self.client_headers: dict[str, str] = dict(client_headers or {}) self.query_parameters: dict[str, str] = dict(query_parameters or {}) + @property + def response_id(self) -> str: + """The public response identifier. + + :rtype: str + """ + return self._identity[0] + + @response_id.setter + def response_id(self, value: str) -> None: + self._identity = (value, self._identity[1]) + + @property + def incarnation_id(self) -> str | None: + """The private durable incarnation, absent on legacy inputs. + + :rtype: str | None + """ + return self._identity[1] + def platform_context(self) -> PlatformContext: """Return the platform context — the single derivation site. @@ -199,10 +257,7 @@ def platform_context(self) -> PlatformContext: replayed for every storage operation over the response's lifetime. :rtype: PlatformContext """ - return PlatformContext( - user_id_key=self.user_id_key, - call_id=self.call_id, - ) + return platform_context_from_params({_K_USER_ID_KEY: self.user_id_key, _K_CALL_ID: self.call_id}) def to_task_input(self) -> dict[str, Any]: """Serialize to the resilient-task input dict — the single producer. @@ -213,6 +268,7 @@ def to_task_input(self) -> dict[str, Any]: :returns: A JSON-serializable dict suitable for the resilient-task input. :rtype: dict[str, Any] :raises TypeError: If any field is not JSON-serializable. + :raises ValueError: If the user partition is malformed. """ params: dict[str, Any] = { _K_RESPONSE_ID: self.response_id, @@ -221,11 +277,13 @@ def to_task_input(self) -> dict[str, Any]: _K_AGENT_REFERENCE: _normalize_agent_reference(self.agent_reference), _K_AGENT_SESSION_ID: self.agent_session_id, _K_AGENT_SESSION_GUID: self.agent_session_guid, - _K_USER_ID_KEY: self.user_id_key, + _K_USER_ID_KEY: user_id_key_from_params({_K_USER_ID_KEY: self.user_id_key}), _K_CALL_ID: self.call_id, _K_CLIENT_HEADERS: dict(self.client_headers), _K_QUERY_PARAMETERS: dict(self.query_parameters), } + if self.incarnation_id is not None: + params[_K_INCARNATION_ID] = self.incarnation_id # Fail-closed guard: prove the boundary is JSON-serializable and ref-free. json.dumps(params) return params @@ -265,8 +323,9 @@ def from_task_input(cls, params: dict[str, Any]) -> "ResilientResponseInput": agent_reference=params.get(_K_AGENT_REFERENCE), agent_session_id=params.get(_K_AGENT_SESSION_ID), agent_session_guid=params.get(_K_AGENT_SESSION_GUID), - user_id_key=params.get(_K_USER_ID_KEY), + user_id_key=user_id_key_from_params(params), call_id=params.get(_K_CALL_ID), client_headers=params.get(_K_CLIENT_HEADERS), query_parameters=params.get(_K_QUERY_PARAMETERS), + incarnation_id=incarnation_from_params(params), ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_orchestrator.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_orchestrator.py index 5c4363f7e51d..f2c8b6780618 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_orchestrator.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_resilient_orchestrator.py @@ -21,6 +21,9 @@ import logging from copy import deepcopy from typing import TYPE_CHECKING, Any, Callable, cast +from uuid import uuid4 + +from anyio import CancelScope from azure.ai.agentserver.core.tasks import ( MultiTurnTask, @@ -29,16 +32,22 @@ multi_turn_task, task, ) +from azure.ai.agentserver.core.streaming import EventStreamClosedError, EventStreamNotFoundError, streams +from azure.ai.agentserver.core.tasks._decorator import _host_bound_task_registration from .._options import ResponsesServerOptions from .._response_context import ResponseExitForRecovery from ._dispatch import DISPOSITION_MARK_FAILED -from ._task_id import derive_task_id, derive_task_session_scope +from ._task_id import derive_lifecycle_id, derive_task_id, derive_task_session_scope +from ._response_task_lifecycle import _input_fence_key from ..models import _generated as _generated_models +from ..streaming._internals import materialize_wire_payload if TYPE_CHECKING: - from .._response_context import ResponseContext + from azure.ai.agentserver.core.tasks._manager import TaskManager + + from .._response_context import PlatformContext, ResponseContext from ..models.runtime import ResponseExecution from ..store._base import ResponseProviderProtocol @@ -206,8 +215,8 @@ def _overlay_failed_terminal( # (Spec 033 §3.1) Process-local cache of typed :class:`RuntimeRefs` (record, # context, parsed request, cancellation signal, runtime state), keyed by -# response_id. These object references cannot be JSON-serialized for -# cross-process recovery, so they live here out-of-band and are NEVER part of +# caller-scoped response incarnation. These object references cannot be +# JSON-serialized for cross-process recovery, so they live out-of-band and are NEVER part of # the persisted resilient-task input (which is the typed # :class:`ResilientResponseInput` alone). The task body fetches refs from this # cache on same-process re-entry; on cross-process recovery the entry is absent @@ -215,6 +224,18 @@ def _overlay_failed_terminal( _RUNTIME_REFS: dict[str, "RuntimeRefs"] = {} +def _discard_runtime_refs(key: str | None, expected_refs: "RuntimeRefs | None") -> None: + """Release only the exact process-local entry owned by this callback. + + :param key: The caller-scoped incarnation key, or None when unidentifiable. + :type key: str | None + :param expected_refs: The entry captured before asynchronous lifecycle work. + :type expected_refs: RuntimeRefs | None + """ + if key is not None and expected_refs is not None and _RUNTIME_REFS.get(key) is expected_refs: + del _RUNTIME_REFS[key] + + def _reconstruct_parsed_from_params(params: dict[str, Any]) -> Any: """Re-parse the persisted request back to a ``CreateResponse`` model. @@ -430,32 +451,8 @@ def __init__( # function and does not need this reference. self._parent_orchestrator = parent_orchestrator - # Opting into resilient background responses implies the durable task - # subsystem must be constructed (and recovery enabled) for this - # deployment. The subsystem is gated SOLELY on the process-global switch - # (``AgentServerHost`` constructs the ``TaskManager`` only when it is - # set), so translate the explicit ``resilient_background`` opt-in into - # that switch here, at host construction time (before the ASGI lifespan - # runs). - # - # NOTE: only ``resilient_background`` auto-enables the subsystem — - # ``steerable_conversations`` intentionally does NOT. Recovery is tied to - # ``resilient_background`` alone; a steerable host that wants durability - # must set ``resilient_background=True`` (or call - # ``set_resilient_tasks_enabled(True)`` explicitly). The two-switch UX is - # a known rough edge to smooth over post-Public-Preview. - # - # A host that leaves ``resilient_background`` off (and does not set the - # switch) constructs no ``TaskManager``: ``store=true`` work degrades to - # non-durable in-process execution (``_start_resilient_background`` - # swallows ``TaskManagerNotInitialized``). - if options.resilient_background: - from azure.ai.agentserver.core.tasks import ( # pylint: disable=import-outside-toplevel - set_resilient_tasks_enabled, - ) - - set_resilient_tasks_enabled(True) - + # The host owns runtime activation. Register callbacks before startup + # recovery without changing the independent Invocations enablement switch. # Spec 023 — per-request primitive dispatch (SOT §6.6). # Two task primitives are registered per deployment; ``_pick_primitive`` # selects per request based on (conversation_id, previous_response_id, @@ -465,10 +462,26 @@ def __init__( # at __init__ time. If the core wheel does not expose both ``@task`` # and ``@multi_turn_task`` symbols, the failure surfaces at server # startup instead of per-request. - one_shot, multi_turn = self._create_task_fns() + # Construction must not publish this host's callbacks into another live host. + with _host_bound_task_registration(): + one_shot, multi_turn = self._create_task_fns() self._one_shot_task_fn: Task[dict[str, Any], None] = one_shot self._multi_turn_task_fn: MultiTurnTask[dict[str, Any], None] = multi_turn + def _register_task_callbacks(self, manager: "TaskManager") -> None: + """Register this orchestrator's callbacks and options on its own manager. + + :param manager: The owning host's task manager. + :type manager: ~azure.ai.agentserver.core.tasks._manager.TaskManager + """ + # pylint: disable=protected-access + primitives = (self._one_shot_task_fn, self._multi_turn_task_fn) + if not (self._options.resilient_background or self._options.steerable_conversations): + manager._disable_recovery_for(*(primitive.name for primitive in primitives)) + return + for primitive in primitives: + manager.register_resume_callback(primitive.name, primitive._fn, primitive._opts) + @property def task_fn(self) -> Task[dict[str, Any], None]: """Deprecated single-task accessor — use ``_one_shot_task_fn`` / @@ -695,6 +708,8 @@ async def _flatten_recovery_context( # (Spec 024 Phase 5 — Proposal #11) Expose the task context so # ``context.exit_for_recovery()`` can delegate to the recovery sentinel. context._task_context = ctx # pylint: disable=protected-access + if context.shutdown.is_set(): + ctx.shutdown.set() if not is_recovery: return False @@ -792,6 +807,7 @@ async def _run_handler_in_task( cancel_bridge: "asyncio.Task[None] | None", parsed_ref: Any, response_id: str, + user_id_key: str | None, stream: bool, agent_reference: Any, model: str | None, @@ -801,6 +817,7 @@ async def _run_handler_in_task( background: bool, history_limit: int, runtime_state: Any, + incarnation_id: str | None = None, ) -> None: """Run the handler body inside the resilient task (Spec 033 §3.2 extract). @@ -823,6 +840,8 @@ async def _run_handler_in_task( :paramtype parsed_ref: Any :keyword response_id: The response id. :paramtype response_id: str + :keyword user_id_key: The user partition from the durable task input. + :paramtype user_id_key: str | None :keyword stream: Whether the request is streaming. :paramtype stream: bool :keyword agent_reference: The normalized agent reference. @@ -841,6 +860,8 @@ async def _run_handler_in_task( :paramtype history_limit: int :keyword runtime_state: The runtime-state tracker. :paramtype runtime_state: Any + :keyword incarnation_id: The durable input's private incarnation identifier. + :paramtype incarnation_id: str | None :return: None :rtype: None """ @@ -848,6 +869,8 @@ async def _run_handler_in_task( _run_background_non_stream, ) + refs_key = _input_fence_key(response_id, user_id_key, incarnation_id) + expected_refs = _RUNTIME_REFS.get(refs_key) try: # Dispatch on the request's stream flag: the streaming pipeline goes # through the parent orchestrator's streaming runner (events flow to @@ -916,9 +939,197 @@ async def _run_handler_in_task( if cancel_bridge is not None and not cancel_bridge.done(): cancel_bridge.cancel() # (Spec 013 US1(c)) Drop the runtime-refs entry on terminal exit. - _RUNTIME_REFS.pop(response_id, None) + _discard_runtime_refs(refs_key, expected_refs) + + async def _execute_in_task(self, ctx: TaskContext[dict[str, Any]]) -> Any: + """Fence deleted inputs and reserve recovered execution before dispatch. + + :param ctx: The resilient task context. + :type ctx: TaskContext[dict[str, Any]] + :return: None when settled, or a task recovery deferral sentinel. + :rtype: Any + """ + from ._resilient_input import ( # pylint: disable=import-outside-toplevel + incarnation_from_params, + platform_context_from_params, + ) + from ._response_task_lifecycle import _task_input_deleted # pylint: disable=import-outside-toplevel + + params = ctx.input + response_id = params.get("response_id") if isinstance(params, dict) else None + malformed_identity = False + platform_context = None + if isinstance(response_id, str): + try: + platform_context = platform_context_from_params(params) + except ValueError: + platform_context = self._trusted_input_identity(ctx, params, response_id) + malformed_identity = True + if not isinstance(response_id, str) or platform_context is None: + return await self._execute_admitted_task(ctx) + user_id_key = platform_context.user_id_key + malformed_input = params if malformed_identity else None + try: + incarnation_id = incarnation_from_params(params) + except ValueError: + incarnation_id = None + malformed_input = params + refs_key = ( + _input_fence_key(response_id, user_id_key, incarnation_id) + if malformed_input is None or malformed_identity + else None + ) + expected_refs = _RUNTIME_REFS.get(refs_key) if refs_key is not None else None + + async def _execute_with_refs() -> Any: + if malformed_identity: + await self._persist_crash_failed( + response_id, + {**params, "user_id_key": user_id_key, "call_id": platform_context.call_id}, + ) + result = None + else: + result = await self._execute_admitted_task(ctx) + if result is None: + _discard_runtime_refs(refs_key, expected_refs) + return result + + state = self._runtime_state + if await _task_input_deleted( + ctx.task_id, response_id, user_id_key, incarnation_id=incarnation_id, malformed_input=malformed_input + ) or (malformed_identity and state is not None and await state.is_deleted(response_id, user_id_key)): + logger.info("Skipping deleted durable response input %s", response_id) + _discard_runtime_refs(refs_key, expected_refs) + return None + if (not _is_recovered_entry(ctx.entry_mode) and not malformed_identity) or state is None: + return await _execute_with_refs() + if not await state.reserve(response_id, user_id_key, recovery=True, incarnation_id=incarnation_id): + if incarnation_id is None and await state.is_deleted(response_id, user_id_key): + _discard_runtime_refs(refs_key, expected_refs) + return None + # A competing lifecycle operation may fail. Preserve the input for + # recovery rather than consuming it while admission is unavailable. + from azure.ai.agentserver.core.tasks._context import ( # pylint: disable=import-outside-toplevel + _ExitForRecovery, + ) + + logger.info("Deferring recovered response %s during a competing lifecycle operation", response_id) + return _ExitForRecovery() + try: + if (incarnation_id is None and malformed_input is None) or not await _task_input_deleted( + ctx.task_id, response_id, user_id_key, incarnation_id=incarnation_id, malformed_input=malformed_input + ): + return await _execute_with_refs() + _discard_runtime_refs(refs_key, expected_refs) + finally: + with CancelScope(shield=True): + await state.release_reservation(response_id, user_id_key) + + def _trusted_input_identity( + self, ctx: TaskContext[dict[str, Any]], params: dict[str, Any], response_id: str + ) -> "PlatformContext | None": + """Prove a malformed input's scope from its original admitted runtime references. + + :param ctx: The framework's current task and input identity. + :type ctx: TaskContext[dict[str, Any]] + :param params: The malformed persisted boundary. + :type params: dict[str, Any] + :param response_id: The public response identifier. + :type response_id: str + :return: An independently proven context, or None without sufficient evidence. + :rtype: PlatformContext | None + """ + from ._resilient_input import incarnation_from_params # pylint: disable=import-outside-toplevel + + input_id = getattr(ctx, "input_id", None) + try: + incarnation = incarnation_from_params(params) + except ValueError: + return None + if not isinstance(input_id, str) or incarnation is None: + return None + refs = _RUNTIME_REFS.get(f"{input_id}:{incarnation}") + if refs is None or self._runtime_state is None or refs.runtime_state is not self._runtime_state: + return None + return self._trusted_runtime_platform_context(ctx, refs, response_id, input_id) + + def _trusted_runtime_platform_context( + self, ctx: TaskContext[dict[str, Any]], refs: "RuntimeRefs", response_id: str, input_id: str + ) -> "PlatformContext | None": + """Validate the cached original context against its record and task run. - async def _execute_in_task(self, ctx: TaskContext[dict[str, Any]]) -> None: + :param ctx: The framework's current task identity. + :type ctx: TaskContext[dict[str, Any]] + :param refs: The exact incarnation's server-owned references. + :type refs: RuntimeRefs + :param response_id: The public response identifier. + :type response_id: str + :param input_id: The caller-scoped task input identifier. + :type input_id: str + :return: A proven platform context, or None. + :rtype: PlatformContext | None + """ + from .._response_context import PlatformContext, ResponseContext # pylint: disable=import-outside-toplevel + from ..models.runtime import ResponseExecution # pylint: disable=import-outside-toplevel + from ._resilient_input import user_id_key_from_params # pylint: disable=import-outside-toplevel + + record, context = refs.record, refs.context + if not isinstance(record, ResponseExecution) or not isinstance(context, ResponseContext): + return None + if record.response_context is not context or not isinstance(context.platform_context, PlatformContext): + return None + try: + user_id_key = user_id_key_from_params({"user_id_key": context.platform_context.user_id_key}) + except ValueError: + return None + if ( + record.response_id != response_id + or context.response_id != response_id + or record.user_id_key != user_id_key + or derive_lifecycle_id(response_id, user_id_key) != input_id + ): + return None + task_run = getattr(record, "resilient_task_run", None) + if ( + getattr(task_run, "task_id", None) != ctx.task_id + or getattr(task_run, "input_id", None) != input_id + or (context.platform_context.call_id is not None and not isinstance(context.platform_context.call_id, str)) + ): + return None + return PlatformContext(user_id_key=user_id_key, call_id=context.platform_context.call_id) + + async def _settle_invalid_input(self, ctx: TaskContext[dict[str, Any]], params: Any) -> None: + """Settle only response storage whose partition is valid independently of parsing. + + :param ctx: The failing task context. + :type ctx: TaskContext[dict[str, Any]] + :param params: The malformed persisted boundary. + :type params: Any + :rtype: None + """ + from ._resilient_input import platform_context_from_params # pylint: disable=import-outside-toplevel + + response_id = params.get("response_id") if isinstance(params, dict) else None + logger.warning( + "Resilient input failed validation for task %s (response_id=%s); " + "failing closed without re-invoking the handler.", + getattr(ctx, "task_id", "?"), + response_id, + ) + if not isinstance(response_id, str) or not response_id: + return + try: + platform_context_from_params(params) + except ValueError: + logger.warning( + "Task %s has no independently proven caller partition; settling only the input " + "and leaving response, replay, and runtime references untouched.", + ctx.task_id, + ) + return + await self._persist_crash_failed(response_id, params) + + async def _execute_admitted_task(self, ctx: TaskContext[dict[str, Any]]) -> Any: """Execute the response pipeline inside the task body. This is the re-entrant function. On each entry: @@ -933,8 +1144,8 @@ async def _execute_in_task(self, ctx: TaskContext[dict[str, Any]]) -> None: :param ctx: The resilient task context. :type ctx: TaskContext[dict[str, Any]] - :return: None - :rtype: None + :return: None when settled, or the handler's recovery deferral sentinel. + :rtype: Any """ # Import here to avoid circular imports from ._resilient_input import ( @@ -957,15 +1168,7 @@ async def _execute_in_task(self, ctx: TaskContext[dict[str, Any]]) -> None: try: resilient = ResilientResponseInput.from_task_input(params) except ValueError: - rid = params.get("response_id") if isinstance(params, dict) else None - logger.warning( - "Resilient input failed validation for task %s (response_id=%s); " - "failing closed without re-invoking the handler.", - getattr(ctx, "task_id", "?"), - rid, - ) - if rid: - await self._persist_crash_failed(rid, params if isinstance(params, dict) else {}) + await self._settle_invalid_input(ctx, params) return None request = resilient.request @@ -990,7 +1193,9 @@ async def _execute_in_task(self, ctx: TaskContext[dict[str, Any]]) -> None: # cache, never in the serialized input. Build a small key→ref map so the # existing ``_ref("_..._ref")`` call sites stay unchanged. Test-injected # refs passed via ``ctx.input`` are honored as a fallback. - _runtime_refs = _RUNTIME_REFS.get(response_id) + _runtime_refs = _RUNTIME_REFS.get( + _input_fence_key(response_id, resilient.user_id_key, resilient.incarnation_id) + ) _ref_map: dict[str, Any] = {} if _runtime_refs is not None: _ref_map = { @@ -1049,7 +1254,6 @@ def _ref(key: str) -> Any: ) assert record is not None, "_reconstruct_from_params guarantees non-None record" assert self._runtime_state is not None, "runtime_state always wired at orchestrator init" - await self._runtime_state.add(record) # After the reconstruction block, context and record are both # guaranteed non-None (either set from refs in the same-process @@ -1063,6 +1267,13 @@ def _ref(key: str) -> Any: if await self._flatten_recovery_context(ctx, context, is_recovery): return + cancellation_signal: asyncio.Event = _ref("_cancel_ref") or record.cancel_signal + record.cancel_signal = cancellation_signal + context._cancellation_signal = cancellation_signal # pylint: disable=protected-access + if _ref("_record_ref") is None: + assert self._runtime_state is not None, "runtime_state always wired at orchestrator init" + if not await self._runtime_state.add(record): + raise RuntimeError("Response lifecycle ownership changed before recovered publication.") # Bridge task cancellation → response cancellation surface. # ``ctx.cancel`` (steering / explicit cancel) and ``ctx.shutdown`` @@ -1083,7 +1294,6 @@ def _ref(key: str) -> Any: # ``context.client_cancelled`` at the HTTP boundary, BEFORE # propagating through ``ctx.cancel`` here. The bridge below # does NOT clobber an existing ``client_cancelled=True``. - cancellation_signal: asyncio.Event = _ref("_cancel_ref") or asyncio.Event() cancel_bridge = self._setup_cancel_bridge(ctx, context, cancellation_signal) # Return the handler-body result so a graceful-shutdown / handler @@ -1097,6 +1307,7 @@ def _ref(key: str) -> Any: cancel_bridge=cancel_bridge, parsed_ref=_ref("_parsed_ref") or request, response_id=response_id, + user_id_key=resilient.user_id_key, stream=_stream, agent_reference=_agent_reference, model=_model, @@ -1106,6 +1317,7 @@ def _ref(key: str) -> Any: background=_background, history_limit=int(self._options.default_fetch_history_count), runtime_state=_ref("_runtime_state_ref") or self._runtime_state, + incarnation_id=resilient.incarnation_id, ) def build_resilient_input( @@ -1152,6 +1364,7 @@ def build_resilient_input( call_id=ctx.call_id, client_headers=dict(ctx.context.client_headers) if ctx.context is not None else {}, query_parameters=dict(ctx.context.query_parameters) if ctx.context is not None else {}, + incarnation_id=uuid4().hex, ) refs = RuntimeRefs( record=record, @@ -1266,7 +1479,8 @@ async def start_resilient( response_id=response_id, steerable=self._options.steerable_conversations, ) - if is_multi_turn and task_id != legacy_task_id: + # Never adopt a shared legacy task for an identified caller. + if is_multi_turn and task_id != legacy_task_id and resilient_input.user_id_key is None: task_id = await self._select_compatible_task_id( picked_primitive, task_id=task_id, @@ -1280,9 +1494,12 @@ async def start_resilient( # semantics) based on the request's conversation_id / # previous_response_id / steerable_conversations tuple. # (Spec 033 §3.1) The process-local refs are cached out-of-band keyed by - # response_id; the resilient task input is EXACTLY the typed boundary's + # caller-scoped incarnation; the resilient task input is EXACTLY the typed boundary's # serialization — the single producer (FR-001). - _RUNTIME_REFS[response_id] = refs + task_id = derive_lifecycle_id(task_id, resilient_input.user_id_key) + lifecycle_id = derive_lifecycle_id(response_id, resilient_input.user_id_key) + refs_key = _input_fence_key(response_id, resilient_input.user_id_key, resilient_input.incarnation_id) + _RUNTIME_REFS[refs_key] = refs start_kwargs: dict[str, Any] = { "task_id": task_id, @@ -1297,9 +1514,11 @@ async def start_resilient( # task_id per request. if is_multi_turn: if response_id is not None: - start_kwargs["input_id"] = response_id + start_kwargs["input_id"] = lifecycle_id if previous_response_id is not None: - start_kwargs["if_last_input_id"] = previous_response_id + start_kwargs["if_last_input_id"] = derive_lifecycle_id( + previous_response_id, resilient_input.user_id_key + ) # ``TaskConflictError`` from the underlying primitive ALWAYS signals # a real conflict (concurrent overlap on a multi-turn-non-steerable @@ -1319,7 +1538,7 @@ async def start_resilient( # out-of-band refs — never runs. Drop the cache entry here so we do # not permanently retain the record/context/parsed-request/cancel # event for a response that fell back to in-process execution. - _RUNTIME_REFS.pop(response_id, None) + _discard_runtime_refs(refs_key, refs) raise # Store the task run reference on the record for observability record.resilient_task_run = task_run # type: ignore[attr-defined] @@ -1333,6 +1552,41 @@ async def start_resilient( is_queued = task_run.is_queued return not is_queued # True = freshly started, False = queued + async def _settle_recovered_stream( + self, + response_id: str, + user_id_key: str | None, + response: _generated_models.ResponseObject, + ) -> None: + """Finish existing replay after a non-resumable recovery settles storage. + + :param response_id: The recovered response identifier. + :type response_id: str + :param user_id_key: The durable input's user partition. + :type user_id_key: str | None + :param response: The successfully persisted terminal snapshot. + :type response: ~azure.ai.agentserver.responses.models.ResponseObject + :rtype: None + """ + try: + subject = await streams.get(derive_lifecycle_id(response_id, user_id_key)) + except EventStreamNotFoundError: + return + cursor = await subject.last_cursor() + snapshot = dict(response) + try: + await subject.emit( + { + "type": f"response.{snapshot['status']}", + "response": materialize_wire_payload(snapshot), + "sequence_number": cursor + 1 if cursor is not None else 0, + }, + close=True, + ) + except EventStreamClosedError: + # A terminal already reached replay before the crash. Preserve it. + await subject.close() + async def _persist_crash_failed( self, response_id: str, @@ -1402,6 +1656,7 @@ async def _persist_crash_failed( # ``update`` would now succeed and overwrite a progressed snapshot with # empty output. existing_snapshot: "_generated_models.ResponseObject | None" = None + existing_status: Any = None response_known_absent = False for _attempt in range(2): try: @@ -1416,7 +1671,6 @@ async def _persist_crash_failed( response_id, existing_status, ) - return existing_snapshot = existing break except (KeyError, FoundryResourceNotFoundError): @@ -1430,6 +1684,10 @@ async def _persist_crash_failed( # to the conservative create-only path below. continue + if existing_snapshot is not None and isinstance(existing_status, str) and existing_status in _TERMINAL_STATUSES: + await self._settle_recovered_stream(response_id, platform_context.user_id_key, existing_snapshot) + return + if existing_snapshot is not None: # Preserve the persisted snapshot; overlay only status + error. failed_response = _overlay_failed_terminal( @@ -1451,11 +1709,13 @@ async def _persist_crash_failed( ), ) + persisted = False if existing_snapshot is not None or response_known_absent: # Safe to write: either we preserve the snapshot (overlay) or the # response is confirmed absent (a create lands a fresh terminal). try: await self._provider.update_response(failed_response, context=platform_context) + persisted = True except (KeyError, FoundryResourceNotFoundError): # Response was never persisted at response.created — try # create instead so the failed terminal still lands. The Foundry @@ -1469,6 +1729,7 @@ async def _persist_crash_failed( history_item_ids=None, context=platform_context, ) + persisted = True except Exception as exc: # pylint: disable=broad-exception-caught logger.error( "_persist_crash_failed: create after update-not-found failed for %s: %s", @@ -1495,9 +1756,12 @@ async def _persist_crash_failed( history_item_ids=None, context=platform_context, ) + persisted = True except Exception as exc: # pylint: disable=broad-exception-caught logger.error( "_persist_crash_failed: create-only (unknown store state) failed for %s: %s", response_id, exc, ) + if persisted: + await self._settle_recovered_stream(response_id, platform_context.user_id_key, failed_response) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_response_task_lifecycle.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_response_task_lifecycle.py new file mode 100644 index 000000000000..35b5525e41de --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_response_task_lifecycle.py @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Caller-scoped ownership and durable deletion fences for response task inputs.""" + +from __future__ import annotations + +from typing import Any + +from azure.ai.agentserver.core.tasks import TaskManagerNotInitialized +from azure.ai.agentserver.core.tasks._attachments import _read_input_value +from azure.ai.agentserver.core.tasks._exceptions import TaskNotFound +from azure.ai.agentserver.core.tasks._exceptions_internal import _HostedConflict +from azure.ai.agentserver.core.tasks._manager import get_task_manager +from azure.ai.agentserver.core.tasks._models import TaskInfo, TaskPatchRequest + +from ._resilient_input import incarnation_from_params, platform_context_from_params +from ._task_id import derive_lifecycle_id + + +_DELETED_INPUT_IDS = "responses_deleted_input_ids" + + +def _input_fence_key(response_id: str, user_id_key: str | None, incarnation_id: str | None = None) -> str: + lifecycle_id = derive_lifecycle_id(response_id, user_id_key) + return lifecycle_id if incarnation_id is None else f"{lifecycle_id}:{incarnation_id}" + + +def _deleted_input_ids(info: TaskInfo) -> list[str]: + value = (info.payload or {}).get(_DELETED_INPUT_IDS, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ValueError("Invalid durable response deletion fence") + return value + + +def _response_fence_keys(info: TaskInfo, response_id: str, user_id_key: str | None) -> list[str]: + lifecycle_id = derive_lifecycle_id(response_id, user_id_key) + if info.status == "completed": + return [] + payload = info.payload or {} + slots = [payload.get("input")] + slots.extend((payload.get("steering") or {}).get("pending_inputs") or []) + keys = [] + for slot in slots: + if slot is None: + continue + value = _read_input_value(slot, info.attachments) + if not isinstance(value, dict): + raise ValueError("Invalid durable response task input") + if value.get("response_id") == response_id and platform_context_from_params(value).user_id_key == user_id_key: + key = _input_fence_key(response_id, user_id_key, incarnation_from_params(value)) + if key not in keys: + keys.append(key) + # Core clears completed input on suspension. A legacy fence covers such + # an unknown old incarnation, but never a new persisted nonce-bearing turn. + if not keys and payload.get("last_input_id") == lifecycle_id: + keys.append(lifecycle_id) + return keys + + +def _owns_response(info: TaskInfo, response_id: str, user_id_key: str | None) -> bool: + deleted = _deleted_input_ids(info) + return any(key not in deleted for key in _response_fence_keys(info, response_id, user_id_key)) + + +async def _response_tasks(response_id: str, user_id_key: str | None, task_names: tuple[str, ...]) -> list[TaskInfo]: + """Find current or queued ownership without starting or reclaiming tasks. + + :param response_id: The public response identifier. + :type response_id: str + :param user_id_key: The authenticated user partition. + :type user_id_key: str | None + :param task_names: The response primitives registered by this host. + :type task_names: tuple[str, ...] + :return: Durable tasks owning the caller-scoped response. + :rtype: list[TaskInfo] + """ + manager = get_task_manager() + matches = [] + for name in task_names: + for info in await manager.list_tasks(fn_name=name): + if _owns_response(info, response_id, user_id_key): + matches.append(info) + return matches + + +async def _fence_response_tasks( + response_id: str, user_id_key: str | None, task_names: tuple[str, ...], *, durable_required: bool = True +) -> None: + """Fence only this response input, retaining other turns and queued work. + + :param response_id: The authorized response being deleted. + :type response_id: str + :param user_id_key: The caller's user partition. + :type user_id_key: str | None + :param task_names: The host's registered response primitives. + :type task_names: tuple[str, ...] + :keyword durable_required: Fail closed when durable admission is enabled but no manager is available. + :paramtype durable_required: bool + :rtype: None + """ + try: + matches = await _response_tasks(response_id, user_id_key, task_names) + except TaskManagerNotInitialized: + if durable_required: + raise + return + if not matches: + return + manager = get_task_manager() + for info in matches: + targets = list( + dict.fromkeys( + [ + derive_lifecycle_id(response_id, user_id_key), + *_response_fence_keys(info, response_id, user_id_key), + ] + ) + ) + for attempt in range(5): + if info.status == "completed": + break # Immutable terminal tasks cannot recover. + deleted = _deleted_input_ids(info) + additions = [key for key in targets if key not in deleted] + if not additions: + break + try: + await manager.provider.update( + info.id, + TaskPatchRequest(if_match=info.etag, payload={_DELETED_INPUT_IDS: [*deleted, *additions]}), + ) + break + except TaskNotFound: + break + except _HostedConflict as exc: + code = exc._code # pylint: disable=protected-access + if code not in {"etag_mismatch", "task_immutable"} or attempt == 4: + raise + try: + latest = await manager.provider.get(info.id) + except TaskNotFound: + break + if latest is None: + break + info = latest + + +async def _task_input_deleted( + task_id: str, + response_id: str, + user_id_key: str | None, + *, + incarnation_id: str | None = None, + malformed_input: dict[str, Any] | None = None, +) -> bool: + """Check durable fencing before any response recovery writes or admission. + + :param task_id: The durable task identifier. + :type task_id: str + :param response_id: The input's public response identifier. + :type response_id: str + :param user_id_key: The input's persisted user partition. + :type user_id_key: str | None + :keyword incarnation_id: The persisted incarnation; None denotes a legacy input. + :paramtype incarnation_id: str | None + :keyword malformed_input: An invalid boundary that may only settle its exact current persisted input. + :paramtype malformed_input: dict[str, Any] | None + :return: Whether the task is gone, terminal, or this input has been deleted. + :rtype: bool + """ + try: + manager = get_task_manager() + except TaskManagerNotInitialized: + if incarnation_id is not None or malformed_input is not None: + raise + return False + try: + info = await manager.provider.get(task_id) + except TaskNotFound: + return True + if ( + info is None + or info.status == "completed" + or _input_fence_key(response_id, user_id_key, incarnation_id) in _deleted_input_ids(info) + ): + return True + value = _read_input_value((info.payload or {}).get("input"), info.attachments) + if malformed_input is not None: + # An invalid nonce cannot prove incarnation ownership. Only the exact + # current boundary, with no legacy deletion fence, may be failed closed. + return not (isinstance(value, dict) and value == malformed_input) + if isinstance(value, dict): + platform_context_from_params(value) + if incarnation_id is None: + return isinstance(value, dict) and "response_incarnation_id" in value + # Admission/recovery must refer to the exact persisted current input, not + # an old callback with a reused response ID. Resume persists this atomically. + return not ( + isinstance(value, dict) + and value.get("response_id") == response_id + and platform_context_from_params(value).user_id_key == user_id_key + and value.get("response_incarnation_id") == incarnation_id + ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_routing.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_routing.py index 988f42adafe1..cf811e3d2abf 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_routing.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_routing.py @@ -12,7 +12,7 @@ import logging import types from collections.abc import AsyncIterable, Awaitable -from typing import Any, AsyncIterator, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Optional, Union from starlette.routing import Route @@ -20,6 +20,7 @@ AgentServerHost, build_server_version, ) +from azure.ai.agentserver.core._middleware import _task_runtime_middleware from .._options import ResponsesServerOptions from .._response_context import ResponseContext @@ -33,6 +34,9 @@ from ._orchestrator import _ResponseOrchestrator, _close_iterator from ._runtime_state import _RuntimeState +if TYPE_CHECKING: + from azure.ai.agentserver.core.tasks._manager import TaskManager + CreateHandlerEvent = Union["_generated_models.ResponseStreamEvent", ResponseCheckpointEvent, dict[str, Any]] CreateHandlerFn = Callable[ @@ -202,11 +206,7 @@ def _log_startup_configuration( runtime_options.shutdown_grace_period_seconds, ) - from azure.ai.agentserver.core.tasks import ( # pylint: disable=import-outside-toplevel - resilient_tasks_enabled, - ) - - if resilient_tasks_enabled(): + if runtime_options.resilient_background or runtime_options.steerable_conversations: logger.info( "Responses resilience: ENABLED - store=true responses run inside durable " "tasks with crash recovery (in-flight responses are recovered/marked failed " @@ -217,7 +217,7 @@ def _log_startup_configuration( "Responses resilience: DISABLED - store=true responses run in-process and are " "NOT durable across an ungraceful crash: a response in-flight when the process " "is hard-killed stays in_progress on a later GET (no mark-failed/recovery). " - "Enable durability via resilient_background, or set_resilient_tasks_enabled(True)." + "Enable the Responses task runtime with resilient_background or steerable_conversations." ) @@ -337,6 +337,9 @@ def __init__( # Build internal components runtime_options = options or ResponsesServerOptions() + task_middleware = _task_runtime_middleware( + self, kwargs, lambda: bool(runtime_options.resilient_background or runtime_options.steerable_conversations) + ) # Server version string — the responses segment is built once and # registered on the host. The full x-platform-server value is @@ -475,30 +478,35 @@ def __init__( endpoint.handle_create, methods=["POST"], name="create_response", + middleware=task_middleware, ), Route( f"{normalized_prefix}/responses/{{response_id}}", endpoint.handle_get, methods=["GET"], name="get_response", + middleware=task_middleware, ), Route( f"{normalized_prefix}/responses/{{response_id}}", endpoint.handle_delete, methods=["DELETE"], name="delete_response", + middleware=task_middleware, ), Route( f"{normalized_prefix}/responses/{{response_id}}/cancel", endpoint.handle_cancel, methods=["POST"], name="cancel_response", + middleware=task_middleware, ), Route( f"{normalized_prefix}/responses/{{response_id}}/input_items", endpoint.handle_input_items, methods=["GET"], name="get_input_items", + middleware=task_middleware, ), ] @@ -535,6 +543,16 @@ def __init__( # --- Responses startup configuration + durability-mode logging --- _log_startup_configuration(resolved_provider, runtime_options) + def _register_task_callbacks(self, manager: "TaskManager") -> None: + """Bind this host's Responses callbacks before recovery starts. + + :param manager: The owning host's task manager. + :type manager: ~azure.ai.agentserver.core.tasks._manager.TaskManager + """ + # pylint: disable=protected-access + super()._register_task_callbacks(manager) + self._endpoint._orchestrator._resilient_orchestrator._register_task_callbacks(manager) + # ------------------------------------------------------------------ # Shutdown notification # ------------------------------------------------------------------ diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py index f1e2d7b107d1..275c530687c4 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py @@ -5,15 +5,24 @@ from __future__ import annotations import asyncio # pylint: disable=do-not-import-asyncio +import logging from copy import deepcopy from typing import Any, cast from ..models.runtime import ResponseExecution +from .._response_context import ResponseContext from ..streaming._helpers import strip_nulls from .. import models as _public_models +_RuntimeKey = tuple[str | None, str] +logger = logging.getLogger(__name__) + + +def _runtime_key(response_id: str, user_id_key: str | None) -> _RuntimeKey: + return (user_id_key, response_id) + def _json_safe_agent_reference(value: Any) -> dict[str, Any]: """Normalize an agent reference to a plain JSON-safe dict for snapshots. @@ -49,41 +58,229 @@ class _RuntimeState: def __init__(self) -> None: """Initialize the runtime state with empty record and deletion sets.""" - self._records: dict[str, ResponseExecution] = {} - self._pending_records: dict[str, ResponseExecution] = {} - self._deleted_response_ids: set[str] = set() + self._records: dict[_RuntimeKey, ResponseExecution] = {} + self._pending_records: dict[_RuntimeKey, ResponseExecution] = {} + self._deleted_response_ids: set[_RuntimeKey] = set() + self._reservations: set[_RuntimeKey] = set() + self._publication_contexts: dict[_RuntimeKey, ResponseContext | None] = {} + self._deletions: set[_RuntimeKey] = set() + self._retained_deletions: set[_RuntimeKey] = set() self._draining = False self._lock = asyncio.Lock() - async def add(self, record: ResponseExecution) -> None: - """Add or replace an execution record in the store. + async def reserve( + self, + response_id: str, + user_id_key: str | None, + *, + recovery: bool = False, + incarnation_id: str | None = None, + publication_context: ResponseContext | None = None, + ) -> bool: + """Reserve a caller-scoped response ID before starting execution. + + :param response_id: The caller-selected response ID. + :type response_id: str + :param user_id_key: The authenticated user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :keyword recovery: Guard recovered admission and retire an exact stopped nonterminal execution. + :paramtype recovery: bool + :keyword incarnation_id: Recovery incarnation already validated against durable task state. + :paramtype incarnation_id: str | None + :keyword publication_context: The fresh request's exact runtime publication owner. + :paramtype publication_context: ResponseContext | None + :return: ``True`` when reserved; ``False`` when draining or lifecycle ownership prevents admission. + :rtype: bool + """ + key = _runtime_key(response_id, user_id_key) + async with self._lock: + if self._draining: + return False + record = self._records.get(key) + stale_execution = ( + recovery + and record is not None + and not record.is_terminal + and record.execution_task is not None + and record.execution_task.done() + ) + if record is not None and not stale_execution: + return False + if recovery and incarnation_id is None and key in self._deleted_response_ids: + return False + if ( + key in self._pending_records + or key in self._reservations + or key in self._deletions + or key in self._retained_deletions + ): + return False + if stale_execution: + del self._records[key] + self._reservations.add(key) + self._publication_contexts[key] = publication_context + return True + + async def release_reservation(self, response_id: str, user_id_key: str | None) -> None: + """Release an unused caller-scoped response-ID reservation. + + :param response_id: The reserved response identifier. + :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :rtype: None + """ + async with self._lock: + self._reservations.discard(_runtime_key(response_id, user_id_key)) + self._publication_contexts.pop(_runtime_key(response_id, user_id_key), None) + + async def begin_deletion(self, response_id: str, user_id_key: str | None) -> bool: + """Block new creates while a caller deletes an existing response. + + :param response_id: The response identifier to delete. + :type response_id: str + :param user_id_key: The caller's user partition. + :type user_id_key: str | None + :return: Whether deletion acquired the scoped lifecycle reservation. + :rtype: bool + """ + key = _runtime_key(response_id, user_id_key) + async with self._lock: + if key in self._deletions: + return False + if key not in self._records and (key in self._reservations or key in self._pending_records): + return False + self._deletions.add(key) + return True + + async def end_deletion(self, response_id: str, user_id_key: str | None) -> None: + """Release a deletion reservation without releasing a create reservation. + + :param response_id: The response identifier being deleted. + :type response_id: str + :param user_id_key: The caller's user partition. + :type user_id_key: str | None + :rtype: None + """ + async with self._lock: + self._deletions.discard(_runtime_key(response_id, user_id_key)) + + async def retain_for_deletion(self, record: ResponseExecution) -> bool: + """Retain exact authorized ownership until all deletion steps succeed. + + :param record: The existing execution or authorized provider snapshot. + :type record: ResponseExecution + :return: Whether the record was retained under the deletion reservation. + :rtype: bool + """ + key = _runtime_key(record.response_id, record.user_id_key) + async with self._lock: + existing = self._records.get(key) + if key not in self._deletions or (existing is not None and existing is not record): + return False + self._records[key] = record + self._retained_deletions.add(key) + return True + + async def is_retained_for_deletion(self, response_id: str, user_id_key: str | None) -> bool: + """Check whether authorized cleanup is awaiting a retry. + + :param response_id: The response identifier. + :type response_id: str + :param user_id_key: The caller's user partition. + :type user_id_key: str | None + :return: Whether deletion owns the retained record. + :rtype: bool + """ + async with self._lock: + return _runtime_key(response_id, user_id_key) in self._retained_deletions + + async def add(self, record: ResponseExecution, *, expected_record: ResponseExecution | None = None) -> bool: + """Publish only within the current execution's scoped lifecycle ownership. :param record: The execution record to store. :type record: ResponseExecution - :return: None - :rtype: None + :keyword expected_record: An exact predecessor for an intentional replacement. + :paramtype expected_record: ResponseExecution | None + :return: Whether publication succeeded without changing another lifecycle. + :rtype: bool """ + key = _runtime_key(record.response_id, record.user_id_key) async with self._lock: - self._pending_records.pop(record.response_id, None) - self._records[record.response_id] = record - self._deleted_response_ids.discard(record.response_id) + existing = self._records.get(key) + pending = self._pending_records.get(key) + context = record.response_context + reserved_context = self._publication_contexts.get(key) + drain_owner = existing if existing is not None else pending + blocked = ( + ( + self._draining + and ( + drain_owner is None + or ( + drain_owner is not record + and (context is None or context is not drain_owner.response_context) + ) + ) + ) + or ( + key in self._deletions + and ( + existing is None + or (existing is not record and (context is None or context is not existing.response_context)) + ) + ) + or key in self._retained_deletions + or (key in self._deleted_response_ids and key not in self._publication_contexts) + or (reserved_context is not None and context is not reserved_context) + or (expected_record is not None and existing is not expected_record and pending is not expected_record) + ) + for owner in (existing, pending): + if owner is not None and owner is not record and owner is not expected_record: + blocked = blocked or context is None or context is not owner.response_context + if blocked: + logger.warning( + "Rejected runtime publication during conflicting response lifecycle: %s", record.response_id + ) + return False + self._pending_records.pop(key, None) + self._records[key] = record + self._deleted_response_ids.discard(key) + if key in self._publication_contexts: + self._publication_contexts[key] = context + return True async def add_pending(self, record: ResponseExecution) -> bool: """Track accepted unpublished work unless shutdown has started. :param record: The execution awaiting its first response event. :type record: ResponseExecution - :return: ``True`` when registered, ``False`` when shutdown is already draining. + :return: Whether this exact execution can be tracked within the current lifecycle. :rtype: bool """ + key = _runtime_key(record.response_id, record.user_id_key) async with self._lock: if self._draining: return False - self._pending_records[record.response_id] = record + if ( + key in self._records + or key in self._pending_records + or key in self._deletions + or key in self._retained_deletions + ): + return False + if key in self._deleted_response_ids and key not in self._publication_contexts: + return False + owner = self._publication_contexts.get(key) + if owner is not None and record.response_context is not owner: + return False + self._pending_records[key] = record + if key in self._publication_contexts: + self._publication_contexts[key] = record.response_context return True async def begin_draining(self) -> list[ResponseExecution]: - """Atomically reject new pending work and snapshot active executions. + """Atomically reject fresh admission and snapshot accepted executions. :return: Published and pending executions accepted before shutdown. :rtype: list[ResponseExecution] @@ -92,57 +289,98 @@ async def begin_draining(self) -> list[ResponseExecution]: self._draining = True return list(self._records.values()) + list(self._pending_records.values()) - async def discard_pending(self, response_id: str) -> None: + async def is_draining(self) -> bool: + """Check whether shutdown has closed admission. + + :return: Whether the shutdown snapshot has been taken. + :rtype: bool + """ + async with self._lock: + return self._draining + + async def discard_pending( + self, + response_id: str, + user_id_key: str | None = None, + *, + expected_record: ResponseExecution | None = None, + ) -> None: """Discard shutdown bookkeeping for an execution that never published. :param response_id: The pending execution's response ID. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :keyword expected_record: Discard only this exact pending execution when supplied. + :paramtype expected_record: ResponseExecution | None :return: None :rtype: None """ async with self._lock: - self._pending_records.pop(response_id, None) + key = _runtime_key(response_id, user_id_key) + if expected_record is None or self._pending_records.get(key) is expected_record: + self._pending_records.pop(key, None) - async def get(self, response_id: str) -> ResponseExecution | None: + async def get(self, response_id: str, user_id_key: str | None = None) -> ResponseExecution | None: """Look up an execution record by response ID. :param response_id: The response ID to look up. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None :return: The matching execution record, or ``None`` if not found. :rtype: ResponseExecution | None """ async with self._lock: - return self._records.get(response_id) + return self._records.get(_runtime_key(response_id, user_id_key)) - async def is_deleted(self, response_id: str) -> bool: + async def is_deleted(self, response_id: str, user_id_key: str | None = None) -> bool: """Check whether a response ID has been deleted. :param response_id: The response ID to check. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None :return: ``True`` if the response was previously deleted. :rtype: bool """ async with self._lock: - return response_id in self._deleted_response_ids - - async def delete(self, response_id: str) -> bool: + return _runtime_key(response_id, user_id_key) in self._deleted_response_ids + + async def delete( + self, + response_id: str, + user_id_key: str | None = None, + *, + expected_record: ResponseExecution | None = None, + ) -> bool: """Delete an execution record by response ID. :param response_id: The response ID to delete. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :keyword expected_record: Only remove this exact record when supplied. + :paramtype expected_record: ResponseExecution | None :return: ``True`` if the record was found and deleted, ``False`` otherwise. :rtype: bool """ + key = _runtime_key(response_id, user_id_key) async with self._lock: - record = self._records.pop(response_id, None) - if record is None: + record = self._records.get(key) + if record is None or (expected_record is not None and record is not expected_record): return False - self._deleted_response_ids.add(response_id) + del self._records[key] + self._retained_deletions.discard(key) + self._deleted_response_ids.add(key) + self._publication_contexts.pop(key, None) return True _TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "incomplete"}) - async def try_evict(self, response_id: str) -> bool: + async def try_evict( + self, response_id: str, user_id_key: str | None = None, *, expected_record: ResponseExecution | None = None + ) -> bool: """Evict a terminal record from in-memory state to free memory. Unlike :meth:`delete`, eviction does **not** mark the response as @@ -151,22 +389,30 @@ async def try_evict(self, response_id: str) -> bool: Only records in a terminal status are evicted. Non-terminal records are left untouched so that in-flight operations remain correct. + In-flight and failed deletion ownership is retained for cleanup retries. :param response_id: The response ID to evict. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :keyword expected_record: Evict only this exact execution when supplied. + :paramtype expected_record: ResponseExecution | None :return: ``True`` if the record was evicted, ``False`` otherwise. :rtype: bool """ + key = _runtime_key(response_id, user_id_key) async with self._lock: - record = self._records.get(response_id) - if record is None: + if key in self._deletions or key in self._retained_deletions: + return False + record = self._records.get(key) + if record is None or (expected_record is not None and record is not expected_record): return False if record.status not in self._TERMINAL_STATUSES: return False - del self._records[response_id] + del self._records[key] return True - async def mark_deleted(self, response_id: str) -> None: + async def mark_deleted(self, response_id: str, user_id_key: str | None = None) -> None: """Mark a response ID as deleted without requiring a runtime record. Used by the delete handler's provider fallback path when the record @@ -174,11 +420,15 @@ async def mark_deleted(self, response_id: str) -> None: :param response_id: The response ID to mark as deleted. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None :return: None :rtype: None """ async with self._lock: - self._deleted_response_ids.add(response_id) + key = _runtime_key(response_id, user_id_key) + self._deleted_response_ids.add(key) + self._publication_contexts.pop(key, None) @staticmethod def check_user_isolation(stored_key: str | None, request_user_id_key: str | None) -> bool: @@ -187,8 +437,6 @@ def check_user_isolation(stored_key: str | None, request_user_id_key: str | None Returns ``True`` if the request is allowed, ``False`` if it should be rejected as not-found to prevent cross-user information leakage. - No enforcement when the response was created without a key (backward compat). - :param stored_key: The user ID key stored at creation time, or ``None``. :type stored_key: str | None :param request_user_id_key: The user ID key from the incoming request, or ``None``. @@ -196,11 +444,11 @@ def check_user_isolation(stored_key: str | None, request_user_id_key: str | None :return: ``True`` if allowed, ``False`` if isolation mismatch. :rtype: bool """ - if stored_key is None: - return True # No enforcement when created without a key return stored_key == request_user_id_key - async def get_input_items(self, response_id: str) -> list[_public_models.OutputItem]: + async def get_input_items( + self, response_id: str, user_id_key: str | None = None + ) -> list[_public_models.OutputItem]: """Retrieve the full input item chain for a response, including ancestors. Walks the ``previous_response_id`` chain to build the complete ordered @@ -208,15 +456,18 @@ async def get_input_items(self, response_id: str) -> list[_public_models.OutputI :param response_id: The response ID whose input items to retrieve. :type response_id: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None :return: Ordered list of deep-copied output items. :rtype: list[OutputItem] :raises ValueError: If the response has been deleted. :raises KeyError: If the response is not found or not visible. """ + key = _runtime_key(response_id, user_id_key) async with self._lock: - record = self._records.get(response_id) + record = self._records.get(key) if record is None: - if response_id in self._deleted_response_ids: + if key in self._deleted_response_ids: raise ValueError(f"response '{response_id}' has been deleted") raise KeyError(f"response '{response_id}' not found") @@ -225,11 +476,14 @@ async def get_input_items(self, response_id: str) -> list[_public_models.OutputI history: list[_public_models.OutputItem] = [] cursor = record.previous_response_id - visited: set[str] = set() + visited: set[_RuntimeKey] = set() - while isinstance(cursor, str) and cursor and cursor not in visited: - visited.add(cursor) - previous = self._records.get(cursor) + while isinstance(cursor, str) and cursor: + cursor_key = _runtime_key(cursor, user_id_key) + if cursor_key in visited: + break + visited.add(cursor_key) + previous = self._records.get(cursor_key) if previous is None: break history = [*deepcopy(previous.input_items), *history] diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_task_id.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_task_id.py index 328a22a576bf..f61b0d10ae29 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_task_id.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_task_id.py @@ -10,6 +10,8 @@ from __future__ import annotations +import hashlib +import json import re from ._chain_id import derive_conversation_chain_id @@ -24,6 +26,25 @@ _TASK_SESSION_SCOPE_SEPARATOR = "\x1f" +def derive_lifecycle_id(identifier: str, user_id_key: str | None) -> str: + """Derive a private user-scoped key without changing public identifiers. + + Anonymous keys retain their previous values for recovery compatibility. + Identified users never fall back to those shared legacy keys. + + :param identifier: The public response or derived task identifier. + :type identifier: str + :param user_id_key: The user partition, or ``None`` for anonymous. + :type user_id_key: str | None + :returns: A stable key for streams, tasks, and process-local references. + :rtype: str + """ + if user_id_key is None: + return identifier + encoded = json.dumps([user_id_key, identifier], ensure_ascii=True, separators=(",", ":")).encode("utf-8") + return "lifecycle-" + hashlib.sha256(encoded).hexdigest() + + def derive_task_session_scope(*, session_id: str, session_guid: str | None) -> str: """Compose the private task namespace for a hosted session incarnation. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py index f711a464a741..3bddb8781645 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py @@ -51,8 +51,9 @@ class ResponseProviderProtocol(Protocol): Every operation accepts an optional ``context`` parameter (S-018). Implementations MUST use it to partition data in multi-tenant - deployments. When ``None``, the provider operates without tenant - scoping (suitable for local development). + deployments. Providers supporting anonymous local development must keep + unkeyed state separate from named users' state; ``None`` must not bypass + partitioning. """ async def create_response( diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py index 8ba4da03869e..d7e40a6087bc 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py @@ -29,9 +29,9 @@ - ``conversation_id`` membership is tracked alongside the ``previous_response_id`` chain so that :meth:`get_history_item_ids` walks both, matching :class:`InMemoryResponseProvider`. -- :class:`PlatformContext` is accepted but ignored, identical to - :class:`InMemoryResponseProvider`. If the in-memory provider ever - starts partitioning by platform context, this provider should follow suit. +- State is partitioned by :class:`PlatformContext.user_id_key`, matching + :class:`InMemoryResponseProvider`. Missing identity uses a distinct anonymous + partition and never bypasses partitioning. **Not for production use.** This is a local-dev convenience. It does not support distributed access, has no SLA, and uses ``asyncio.Lock`` for @@ -40,20 +40,20 @@ Storage layout under ``storage_dir``:: - responses/ - {response_id}.json # envelope; output[] entries are - # pointer stubs {"$item_ref": id} - # for id'd items (id-less items - # stay inline). get_response - # rehydrates from items/. - {response_id}.indexes.json # input/output/history id lists - # (the only place history_item_ids - # is read from) - {response_id}.deleted # soft-delete marker - items/ # THE single copy of each item - {item_id}.json - conversations/ # response_id list per conversation - {conversation_id}.json + partitions-v1/ + {anonymous|user-{sha256(user_id_key)}}/ + responses/ + {response_id}.json + {response_id}.indexes.json + {response_id}.deleted + items/ + {item_id}.json + conversations/ + {conversation_id}.json + +The partition directory is ``anonymous`` when context or ``user_id_key`` is +missing; otherwise it is ``user-{sha256(user_id_key)}``. Both use the same +``responses/``, ``items/``, and ``conversations/`` subtree. Each item is persisted exactly once under ``items/``; the response envelope and conversations hold only pointers (spec 028). ``get_items`` @@ -70,11 +70,13 @@ from __future__ import annotations import asyncio # pylint: disable=do-not-import-asyncio +import hashlib import json import os import re import shutil from copy import deepcopy +from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable, cast @@ -98,6 +100,16 @@ _VALID_STORE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$") +@dataclass(frozen=True) +class _PartitionPaths: + """Filesystem paths for one isolated response-store partition.""" + + root: Path + responses: Path + items: Path + conversations: Path + + def _validate_store_id(value: str, kind: str) -> str: """Return *value* if it is a safe on-disk identifier, else raise. @@ -228,8 +240,8 @@ class FileResponseStore(ResponseProviderProtocol): by the responses hosting layer. :param storage_dir: Root directory for the store. Created if it does - not exist. Subdirectories ``responses/``, ``items/``, and - ``conversations/`` are managed by the store. Defaults to + not exist. User-scoped data is managed under ``partitions-v1/``. + Defaults to ``resolve_state_subdir("responses")`` — a ``responses`` directory under the shared agent-server state root (``AGENTSERVER_STATE_ROOT``, or ``~/.agentserver`` when unset), alongside ``tasks`` and @@ -245,38 +257,52 @@ def __init__(self, storage_dir: str | Path | None = None) -> None: storage_dir = cast("str | Path", resolve_state_subdir("responses")) self._root = Path(storage_dir) - self._responses_dir = self._root / "responses" - self._items_dir_global = self._root / "items" - self._conversations_dir = self._root / "conversations" - for d in ( - self._responses_dir, - self._items_dir_global, - self._conversations_dir, - ): - d.mkdir(parents=True, exist_ok=True) + self._partitions_dir = self._root / "partitions-v1" + self._partitions_dir.mkdir(parents=True, exist_ok=True) self._lock = asyncio.Lock() # ------------------------------------------------------------------ # Path helpers # ------------------------------------------------------------------ - def _response_path(self, response_id: str) -> Path: - return self._responses_dir / f"{_validate_store_id(response_id, 'response_id')}.json" + def _partition_paths(self, context: PlatformContext | None) -> _PartitionPaths: + user_id_key = context.user_id_key if context is not None else None + if user_id_key is None: + partition_name = "anonymous" + else: + digest = hashlib.sha256(user_id_key.encode("utf-8")).hexdigest() + partition_name = f"user-{digest}" + root = self._partitions_dir / partition_name + return _PartitionPaths( + root=root, + responses=root / "responses", + items=root / "items", + conversations=root / "conversations", + ) - def _per_response_items_dir(self, response_id: str) -> Path: - return self._responses_dir / f"{_validate_store_id(response_id, 'response_id')}.items" + @staticmethod + def _response_path(paths: _PartitionPaths, response_id: str) -> Path: + return paths.responses / f"{_validate_store_id(response_id, 'response_id')}.json" + + @staticmethod + def _per_response_items_dir(paths: _PartitionPaths, response_id: str) -> Path: + return paths.responses / f"{_validate_store_id(response_id, 'response_id')}.items" - def _indexes_path(self, response_id: str) -> Path: - return self._responses_dir / f"{_validate_store_id(response_id, 'response_id')}.indexes.json" + @staticmethod + def _indexes_path(paths: _PartitionPaths, response_id: str) -> Path: + return paths.responses / f"{_validate_store_id(response_id, 'response_id')}.indexes.json" - def _deleted_marker(self, response_id: str) -> Path: - return self._responses_dir / f"{_validate_store_id(response_id, 'response_id')}.deleted" + @staticmethod + def _deleted_marker(paths: _PartitionPaths, response_id: str) -> Path: + return paths.responses / f"{_validate_store_id(response_id, 'response_id')}.deleted" - def _global_item_path(self, item_id: str) -> Path: - return self._items_dir_global / f"{_validate_store_id(item_id, 'item_id')}.json" + @staticmethod + def _global_item_path(paths: _PartitionPaths, item_id: str) -> Path: + return paths.items / f"{_validate_store_id(item_id, 'item_id')}.json" - def _conversation_path(self, conversation_id: str) -> Path: - return self._conversations_dir / f"{_validate_store_id(conversation_id, 'conversation_id')}.json" + @staticmethod + def _conversation_path(paths: _PartitionPaths, conversation_id: str) -> Path: + return paths.conversations / f"{_validate_store_id(conversation_id, 'conversation_id')}.json" # ------------------------------------------------------------------ # ResponseProviderProtocol — envelope CRUD @@ -298,18 +324,17 @@ async def create_response( :type input_items: Iterable[OutputItem] | None :param history_item_ids: Optional history item ids to link. :type history_item_ids: Iterable[str] | None - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :rtype: None :raises ResponseAlreadyExistsError: If a non-deleted response with the same id already exists. """ - del context + paths = self._partition_paths(context) response_id = str(response.get("id")) async with self._lock: - target = self._response_path(response_id) - deleted_marker = self._deleted_marker(response_id) + target = self._response_path(paths, response_id) + deleted_marker = self._deleted_marker(paths, response_id) if target.exists() and not deleted_marker.exists(): raise ResponseAlreadyExistsError(response_id) if deleted_marker.exists(): @@ -317,19 +342,19 @@ async def create_response( # (Spec 028) Best-effort removal of any legacy per-response items # directory from a pre-normalization layout — it is dead weight. - legacy_items = self._per_response_items_dir(response_id) + legacy_items = self._per_response_items_dir(paths, response_id) if legacy_items.exists(): shutil.rmtree(legacy_items, ignore_errors=True) # Items first, pointerized envelope last: a crash can never leave # the envelope referencing an item file that does not exist. - input_ids = self._store_items_unlocked(input_items or []) - output_ids = self._store_output_items_unlocked(response) + input_ids = self._store_items_unlocked(paths, input_items or []) + output_ids = self._store_output_items_unlocked(paths, response) history_ids = list(history_item_ids) if history_item_ids is not None else [] _atomic_write_json(target, self._pointerize_output(_response_to_dict(response))) _atomic_write_json( - self._indexes_path(response_id), + self._indexes_path(paths, response_id), { "input_item_ids": input_ids, "output_item_ids": output_ids, @@ -339,13 +364,13 @@ async def create_response( # (Spec 028) Best-effort removal of a legacy per-response # history file from a pre-normalization layout — history_item_ids # live in indexes.json (the only place any reader consults). - legacy_history = self._responses_dir / f"{response_id}.history.json" + legacy_history = paths.responses / f"{response_id}.history.json" if legacy_history.exists(): legacy_history.unlink() conversation_id = get_conversation_id(response) if conversation_id is not None: - self._add_response_to_conversation_unlocked(conversation_id, response_id) + self._add_response_to_conversation_unlocked(paths, conversation_id, response_id) async def get_response( self, response_id: str, *, context: PlatformContext | None = None @@ -354,21 +379,20 @@ async def get_response( :param response_id: The response identifier. :type response_id: str - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :returns: The persisted response envelope (deep-copied). :rtype: ResponseObject :raises KeyError: If the response does not exist or has been deleted. """ - del context + paths = self._partition_paths(context) async with self._lock: - if self._deleted_marker(response_id).exists(): + if self._deleted_marker(paths, response_id).exists(): raise KeyError(f"response '{response_id}' not found") - data = _read_json_or_none(self._response_path(response_id)) + data = _read_json_or_none(self._response_path(paths, response_id)) if data is None: raise KeyError(f"response '{response_id}' not found") - return _dict_to_response(deepcopy(self._rehydrate_output(data))) + return _dict_to_response(deepcopy(self._rehydrate_output(paths, data))) async def update_response( self, response: _generated_models.ResponseObject, *, context: PlatformContext | None = None @@ -382,26 +406,25 @@ async def update_response( :param response: The new response envelope. :type response: ResponseObject - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :rtype: None :raises KeyError: If the response does not exist or has been deleted. """ - del context + paths = self._partition_paths(context) response_id = str(response.get("id")) async with self._lock: - if self._deleted_marker(response_id).exists(): + if self._deleted_marker(paths, response_id).exists(): raise KeyError(f"response '{response_id}' not found") - target = self._response_path(response_id) + target = self._response_path(paths, response_id) if not target.exists(): raise KeyError(f"response '{response_id}' not found") response_dict = _response_to_dict(response) # Items first, pointerized envelope last (spec 028 — same # crash-ordering invariant as create_response). - output_ids = self._store_output_items_unlocked(response) + output_ids = self._store_output_items_unlocked(paths, response) _atomic_write_json(target, self._pointerize_output(response_dict)) - self._update_indexes_unlocked(response_id, output_item_ids=output_ids) + self._update_indexes_unlocked(paths, response_id, output_item_ids=output_ids) async def delete_response(self, response_id: str, *, context: PlatformContext | None = None) -> None: """Soft-delete a stored response envelope by identifier. @@ -413,20 +436,19 @@ async def delete_response(self, response_id: str, *, context: PlatformContext | :param response_id: The response identifier. :type response_id: str - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :rtype: None :raises KeyError: If the response does not exist or has already been deleted. """ - del context + paths = self._partition_paths(context) async with self._lock: - if self._deleted_marker(response_id).exists(): + if self._deleted_marker(paths, response_id).exists(): raise KeyError(f"response '{response_id}' not found") - target = self._response_path(response_id) + target = self._response_path(paths, response_id) if not target.exists(): raise KeyError(f"response '{response_id}' not found") - self._deleted_marker(response_id).write_text("deleted") + self._deleted_marker(paths, response_id).write_text("deleted") # ------------------------------------------------------------------ # ResponseProviderProtocol — items + history @@ -459,23 +481,22 @@ async def get_input_items( :type after: str | None :param before: Cursor — return items before this id. :type before: str | None - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :returns: Paginated list of items. :rtype: list[OutputItem] :raises KeyError: If the response does not exist. :raises ValueError: If the response has been deleted. """ - del context + paths = self._partition_paths(context) async with self._lock: - target = self._response_path(response_id) + target = self._response_path(paths, response_id) if not target.exists(): raise KeyError(f"response '{response_id}' not found") - if self._deleted_marker(response_id).exists(): + if self._deleted_marker(paths, response_id).exists(): raise ValueError(f"response '{response_id}' has been deleted") - indexes = _read_json_or_none(self._indexes_path(response_id)) or {} + indexes = _read_json_or_none(self._indexes_path(paths, response_id)) or {} item_ids = [ *(indexes.get("history_item_ids") or []), *(indexes.get("input_item_ids") or []), @@ -494,7 +515,7 @@ async def get_input_items( safe_limit = max(1, min(100, int(limit))) results: list[_generated_models.OutputItem] = [] for iid in ordered[:safe_limit]: - data = _read_json_or_none(self._global_item_path(iid)) + data = _read_json_or_none(self._global_item_path(paths, iid)) item = _deserialize_item(data) if item is not None: results.append(item) @@ -513,17 +534,16 @@ async def get_items( :param item_ids: The item ids to look up. :type item_ids: Iterable[str] - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :returns: Items in the same order as ``item_ids``, ``None`` for misses. :rtype: list[OutputItem | None] """ - del context + paths = self._partition_paths(context) async with self._lock: results: list[_generated_models.OutputItem | None] = [] for iid in item_ids: - data = _read_json_or_none(self._global_item_path(iid)) + data = _read_json_or_none(self._global_item_path(paths, iid)) results.append(_deserialize_item(data)) return results @@ -557,29 +577,28 @@ async def get_history_item_ids( :type conversation_id: str | None :param limit: Maximum number of item IDs to return (most recent N), or -1 for all items. :type limit: int - :keyword context: Platform context (accepted but unused — - matches :class:`InMemoryResponseProvider`). + :keyword context: Platform context selecting the isolated user partition. :paramtype context: PlatformContext | None :returns: Ordered list of unique item IDs from the resolved chain (possibly empty). :rtype: list[str] """ - del context + paths = self._partition_paths(context) async with self._lock: resolved: list[str] = [] - if previous_response_id is not None and not self._deleted_marker(previous_response_id).exists(): - indexes = _read_json_or_none(self._indexes_path(previous_response_id)) + if previous_response_id is not None and not self._deleted_marker(paths, previous_response_id).exists(): + indexes = _read_json_or_none(self._indexes_path(paths, previous_response_id)) if indexes is not None: resolved.extend(indexes.get("history_item_ids") or []) resolved.extend(indexes.get("input_item_ids") or []) resolved.extend(indexes.get("output_item_ids") or []) if conversation_id is not None: - conv_data = _read_json_or_none(self._conversation_path(conversation_id)) + conv_data = _read_json_or_none(self._conversation_path(paths, conversation_id)) for rid in (conv_data or {}).get("response_ids", []): - if self._deleted_marker(rid).exists(): + if self._deleted_marker(paths, rid).exists(): continue - indexes = _read_json_or_none(self._indexes_path(rid)) + indexes = _read_json_or_none(self._indexes_path(paths, rid)) if indexes is None: continue resolved.extend(indexes.get("history_item_ids") or []) @@ -599,9 +618,11 @@ async def get_history_item_ids( # Internal helpers (must be called with self._lock held) # ------------------------------------------------------------------ - def _store_items_unlocked(self, items: Iterable[Any]) -> list[str]: - """Persist items to the single global ``items/`` store. + def _store_items_unlocked(self, paths: _PartitionPaths, items: Iterable[Any]) -> list[str]: + """Persist items to this partition's ``items/`` store. + :param paths: Storage directories for the user partition. + :type paths: _PartitionPaths :param items: Iterable of items (each must expose an ``id``). :type items: Iterable[Any] :returns: Ordered list of stored item ids. @@ -612,15 +633,19 @@ def _store_items_unlocked(self, items: Iterable[Any]) -> list[str]: iid = _item_id(item) if not iid: continue - _atomic_write_json(self._global_item_path(iid), _serialize_item(item)) + _atomic_write_json(self._global_item_path(paths, iid), _serialize_item(item)) stored_ids.append(iid) return stored_ids - def _store_output_items_unlocked(self, response: _generated_models.ResponseObject) -> list[str]: + def _store_output_items_unlocked( + self, paths: _PartitionPaths, response: _generated_models.ResponseObject + ) -> list[str]: """Extract output items from a response and persist them. Mirrors :meth:`InMemoryResponseProvider._store_output_items_unlocked`. + :param paths: Storage directories for the user partition. + :type paths: _PartitionPaths :param response: The response envelope. :type response: ResponseObject :returns: Ordered list of stored output item ids. @@ -629,7 +654,7 @@ def _store_output_items_unlocked(self, response: _generated_models.ResponseObjec output = response.get("output") if not output: return [] - return self._store_items_unlocked(output) + return self._store_items_unlocked(paths, output) @staticmethod def _pointerize_output(envelope: dict[str, Any]) -> dict[str, Any]: @@ -656,13 +681,15 @@ def _pointerize_output(envelope: dict[str, Any]) -> dict[str, Any]: envelope["output"] = new_output return envelope - def _rehydrate_output(self, envelope: dict[str, Any]) -> dict[str, Any]: + def _rehydrate_output(self, paths: _PartitionPaths, envelope: dict[str, Any]) -> dict[str, Any]: """Substitute ``output[]`` pointer stubs with item content from ``items/``. Inverse of :meth:`_pointerize_output`. Non-stub entries (id-less items, or legacy fully-inline items) are kept as-is, preserving order and position. + :param paths: Storage directories for the user partition. + :type paths: _PartitionPaths :param envelope: The persisted response envelope dict. :type envelope: dict[str, Any] :returns: A shallow copy of *envelope* with ``output`` rehydrated. @@ -684,7 +711,7 @@ def _rehydrate_output(self, envelope: dict[str, Any]) -> dict[str, Any]: and isinstance(entry[_ITEM_REF_KEY], str) ): iid = entry[_ITEM_REF_KEY] - item = _read_json_or_none(self._global_item_path(iid)) + item = _read_json_or_none(self._global_item_path(paths, iid)) if item is None: raise ResponseStoreCorruptionError( f"FileResponseStore: response envelope references item " @@ -699,6 +726,7 @@ def _rehydrate_output(self, envelope: dict[str, Any]) -> dict[str, Any]: def _update_indexes_unlocked( self, + paths: _PartitionPaths, response_id: str, *, input_item_ids: list[str] | None = None, @@ -707,6 +735,8 @@ def _update_indexes_unlocked( ) -> None: """Merge the supplied id lists into the persisted indexes file. + :param paths: Storage directories for the user partition. + :type paths: _PartitionPaths :param response_id: The response identifier. :type response_id: str :keyword input_item_ids: New input ids to overwrite. @@ -714,7 +744,7 @@ def _update_indexes_unlocked( :keyword history_item_ids: New history ids to overwrite. :rtype: None """ - path = self._indexes_path(response_id) + path = self._indexes_path(paths, response_id) current = _read_json_or_none(path) or {} if input_item_ids is not None: current["input_item_ids"] = input_item_ids @@ -724,18 +754,22 @@ def _update_indexes_unlocked( current["history_item_ids"] = history_item_ids _atomic_write_json(path, current) - def _add_response_to_conversation_unlocked(self, conversation_id: str, response_id: str) -> None: + def _add_response_to_conversation_unlocked( + self, paths: _PartitionPaths, conversation_id: str, response_id: str + ) -> None: """Append ``response_id`` to the conversation's response list. Idempotent: appending the same id twice is a no-op. + :param paths: Storage directories for the user partition. + :type paths: _PartitionPaths :param conversation_id: The conversation identifier. :type conversation_id: str :param response_id: The response identifier to register. :type response_id: str :rtype: None """ - path = self._conversation_path(conversation_id) + path = self._conversation_path(paths, conversation_id) data = _read_json_or_none(path) or {"response_ids": []} ids = list(data.get("response_ids") or []) if response_id not in ids: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py index 32bac6788d58..d8410b7543c1 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_errors.py @@ -7,12 +7,11 @@ import json from typing import TYPE_CHECKING, Any +from azure.ai.agentserver.core import experimental from azure.ai.agentserver.core.platform_headers import ( PLATFORM_ERROR_TAG, ) -from .._experimental import experimental - if TYPE_CHECKING: from azure.core.rest import HttpResponse diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py index d0b0155c3da4..e8ea6fb625ed 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterable from urllib.parse import quote as _url_quote +from azure.ai.agentserver.core import experimental from azure.ai.agentserver.core.platform_headers import FOUNDRY_CALL_ID, PLATFORM_ERROR_TAG from azure.core import AsyncPipelineClient from azure.core.credentials_async import AsyncTokenCredential @@ -32,7 +33,6 @@ serialize_response, ) from ._foundry_settings import FoundryStorageSettings -from .._experimental import experimental if TYPE_CHECKING: from .._response_context import PlatformContext diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py index 7dff3e79bf12..b03924a48658 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_settings.py @@ -6,9 +6,7 @@ from urllib.parse import quote as _url_quote -from azure.ai.agentserver.core import AgentConfig - -from .._experimental import experimental +from azure.ai.agentserver.core import AgentConfig, experimental _API_VERSION = "v1" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py index 9e3bd41875fa..22c78a471e93 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py @@ -22,6 +22,21 @@ _DEFAULT_REPLAY_EVENT_TTL_SECONDS: int = 600 """Minimum per-event replay TTL (10 minutes) per spec B35.""" +_StoreKey = tuple[str | None, str] + + +def _store_key(identifier: str, context: PlatformContext | None) -> _StoreKey: + """Keep absent identity anonymous; preserve present keys exactly, including empty strings. + + :param identifier: The identifier within the user partition. + :type identifier: str + :param context: Platform context carrying the user key, or ``None``. + :type context: PlatformContext | None + :return: The user key and identifier. + :rtype: tuple[str | None, str] + """ + return (context.user_id_key if context is not None else None, identifier) + class _StoreEntry: """Container for one response execution and its replay state.""" @@ -57,15 +72,21 @@ class InMemoryResponseProvider(ResponseProviderProtocol): process-wide ``azure.ai.agentserver.core.streaming.streams`` registry, configured at host startup; this provider stores only response envelopes, input items, and history pointers. + + State is partitioned by the trusted ``PlatformContext.user_id_key``. + Missing context or a ``None`` user key selects a separate anonymous + partition for local use, never an unrestricted lookup. Empty and whitespace + keys remain distinct, matching ``PlatformContext`` semantics. The provider + does not authenticate callers or interpret ``call_id``. """ def __init__(self) -> None: """Initialize in-memory state and an async mutation lock.""" - self._entries: Dict[str, _StoreEntry] = {} + self._entries: Dict[_StoreKey, _StoreEntry] = {} self._lock = asyncio.Lock() - self._item_store: Dict[str, _generated_models.OutputItem] = {} - self._conversation_responses: defaultdict[str, list[str]] = defaultdict(list) - self._stream_events: Dict[str, list[_generated_models.ResponseStreamEvent]] = {} + self._item_store: Dict[_StoreKey, _generated_models.OutputItem] = {} + self._conversation_responses: defaultdict[_StoreKey, list[str]] = defaultdict(list) + self._stream_events: Dict[_StoreKey, list[_generated_models.ResponseStreamEvent]] = {} @contextlib.asynccontextmanager async def _locked(self) -> AsyncIterator[None]: @@ -104,7 +125,7 @@ async def create_response( """ response_id = str(response.get("id")) async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is not None and not entry.deleted: raise ResponseAlreadyExistsError(response_id) @@ -114,12 +135,12 @@ async def create_response( item_id = self._extract_item_id(item) if item_id is None: continue - self._item_store[item_id] = deepcopy(item) + self._item_store[_store_key(item_id, context)] = deepcopy(item) input_ids.append(item_id) history_ids = list(history_item_ids) if history_item_ids is not None else [] - output_ids = self._store_output_items_unlocked(response) - self._entries[response_id] = _StoreEntry( + output_ids = self._store_output_items_unlocked(response, context=context) + self._entries[_store_key(response_id, context)] = _StoreEntry( execution=ResponseExecution( response_id=response_id, mode_flags=self._resolve_mode_flags_from_response(response), @@ -134,7 +155,7 @@ async def create_response( conversation_id = get_conversation_id(response) if conversation_id is not None: - self._conversation_responses[conversation_id].append(response_id) + self._conversation_responses[_store_key(conversation_id, context)].append(response_id) async def get_response( self, response_id: str, *, context: PlatformContext | None = None @@ -150,7 +171,7 @@ async def get_response( :raises KeyError: If the response does not exist or has been deleted. """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None or entry.deleted or entry.response is None: raise KeyError(f"response '{response_id}' not found") return deepcopy(entry.response) @@ -172,13 +193,13 @@ async def update_response( """ response_id = str(response.get("id")) async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None or entry.deleted: raise KeyError(f"response '{response_id}' not found") entry.response = deepcopy(response) entry.execution.set_response_snapshot(deepcopy(response)) - entry.output_item_ids = self._store_output_items_unlocked(response) + entry.output_item_ids = self._store_output_items_unlocked(response, context=context) async def delete_response(self, response_id: str, *, context: PlatformContext | None = None) -> None: """Delete a stored response envelope by identifier. @@ -193,7 +214,7 @@ async def delete_response(self, response_id: str, *, context: PlatformContext | :raises KeyError: If the response does not exist or has already been deleted. """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None or entry.deleted: raise KeyError(f"response '{response_id}' not found") entry.deleted = True @@ -232,7 +253,7 @@ async def get_input_items( :raises ValueError: If the response has been deleted. """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: raise KeyError(f"response '{response_id}' not found") if entry.deleted: @@ -257,9 +278,9 @@ async def get_input_items( safe_limit = max(1, min(100, int(limit))) return [ - deepcopy(self._item_store[item_id]) + deepcopy(self._item_store[key]) for item_id in ordered_ids[:safe_limit] - if item_id in self._item_store + if (key := _store_key(item_id, context)) in self._item_store ] async def get_items( @@ -281,7 +302,8 @@ async def get_items( """ async with self._locked(): return [ - deepcopy(self._item_store[item_id]) if item_id in self._item_store else None for item_id in item_ids + deepcopy(self._item_store[key]) if (key := _store_key(item_id, context)) in self._item_store else None + for item_id in item_ids ] async def get_history_item_ids( @@ -315,7 +337,7 @@ async def get_history_item_ids( resolved: list[str] = [] if previous_response_id is not None: - entry = self._entries.get(previous_response_id) + entry = self._entries.get(_store_key(previous_response_id, context)) if entry is not None and not entry.deleted: # Resolve history chain for the previous response: # return historyItemIds + inputItemIds + outputItemIds of the previous response @@ -324,8 +346,8 @@ async def get_history_item_ids( resolved.extend(entry.output_item_ids or []) if conversation_id is not None: - for response_id in self._conversation_responses.get(conversation_id, []): - entry = self._entries.get(response_id) + for response_id in self._conversation_responses.get(_store_key(conversation_id, context), []): + entry = self._entries.get(_store_key(response_id, context)) if entry is None or entry.deleted: continue resolved.extend(entry.history_item_ids or []) @@ -341,35 +363,48 @@ async def get_history_item_ids( # preserving chronological order in the returned slice. return resolved[-limit:] - async def create_execution(self, execution: ResponseExecution, *, ttl_seconds: int | None = None) -> None: + async def create_execution( + self, + execution: ResponseExecution, + *, + ttl_seconds: int | None = None, + context: PlatformContext | None = None, + ) -> None: """Create a new execution and replay container for ``execution.response_id``. :param execution: The execution state to store. :type execution: ~azure.ai.agentserver.responses.models.runtime.ResponseExecution :keyword int or None ttl_seconds: Optional time-to-live in seconds for automatic expiration. + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None :raises ValueError: If an entry with the same response ID already exists. """ async with self._locked(): - if execution.response_id in self._entries: + key = _store_key(execution.response_id, context) + if key in self._entries: raise ValueError(f"response '{execution.response_id}' already exists") - self._entries[execution.response_id] = _StoreEntry( + self._entries[key] = _StoreEntry( execution=deepcopy(execution), replay=_StreamReplayState(response_id=execution.response_id), expires_at=self._compute_expiry(ttl_seconds), ) - async def get_execution(self, response_id: str) -> ResponseExecution | None: + async def get_execution( + self, response_id: str, *, context: PlatformContext | None = None + ) -> ResponseExecution | None: """Get a defensive copy of execution state for ``response_id`` if present. :param response_id: The unique identifier of the response execution to retrieve. :type response_id: str + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A deep copy of the execution state, or ``None`` if not found. :rtype: ~azure.ai.agentserver.responses.models.runtime.ResponseExecution | None """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return None return deepcopy(entry.execution) @@ -380,6 +415,7 @@ async def set_response_snapshot( response: _generated_models.ResponseObject, *, ttl_seconds: int | None = None, + context: PlatformContext | None = None, ) -> bool: """Set the latest response snapshot for an existing response execution. @@ -388,11 +424,13 @@ async def set_response_snapshot( :param response: The response snapshot to associate with the execution. :type response: ~azure.ai.agentserver.responses.models._generated.Response :keyword int or None ttl_seconds: Optional time-to-live in seconds to refresh expiration. + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: ``True`` if the entry was found and updated, ``False`` otherwise. :rtype: bool """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return False @@ -406,6 +444,7 @@ async def transition_execution_status( next_status: ResponseStatus, *, ttl_seconds: int | None = None, + context: PlatformContext | None = None, ) -> bool: """Transition execution state while preserving lifecycle invariants. @@ -414,11 +453,13 @@ async def transition_execution_status( :param next_status: The target status to transition to. :type next_status: ~azure.ai.agentserver.responses.models.runtime.ResponseStatus :keyword int or None ttl_seconds: Optional time-to-live in seconds to refresh expiration. + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: ``True`` if the entry was found and transitioned, ``False`` otherwise. :rtype: bool """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return False @@ -426,18 +467,26 @@ async def transition_execution_status( self._apply_ttl_unlocked(entry, ttl_seconds) return True - async def set_cancel_requested(self, response_id: str, *, ttl_seconds: int | None = None) -> bool: + async def set_cancel_requested( + self, + response_id: str, + *, + ttl_seconds: int | None = None, + context: PlatformContext | None = None, + ) -> bool: """Mark cancellation requested and enforce lifecycle-safe cancel transitions. :param response_id: The unique identifier of the response to cancel. :type response_id: str :keyword int or None ttl_seconds: Optional time-to-live in seconds to refresh expiration. + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: ``True`` if the entry was found and cancel was applied, ``False`` otherwise. :rtype: bool :raises ValueError: If the execution is already terminal in a non-cancelled state. """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return False @@ -479,6 +528,7 @@ async def append_stream_event( event: StreamEventRecord, *, ttl_seconds: int | None = None, + context: PlatformContext | None = None, ) -> bool: """Append one stream event to replay state for an existing execution. @@ -487,11 +537,13 @@ async def append_stream_event( :param event: The stream event record to append. :type event: ~azure.ai.agentserver.responses.models.runtime.StreamEventRecord :keyword int or None ttl_seconds: Optional time-to-live in seconds to refresh expiration. + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: ``True`` if the entry was found and the event was appended, ``False`` otherwise. :rtype: bool """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return False @@ -499,7 +551,9 @@ async def append_stream_event( self._apply_ttl_unlocked(entry, ttl_seconds) return True - async def get_replay_events(self, response_id: str) -> list[StreamEventRecord] | None: + async def get_replay_events( + self, response_id: str, *, context: PlatformContext | None = None + ) -> list[StreamEventRecord] | None: """Get defensive copies of replay events for ``response_id``, filtering out expired events. Events older than the entry's ``replay_event_ttl_seconds`` (default 600s / 10 minutes, @@ -507,18 +561,20 @@ async def get_replay_events(self, response_id: str) -> list[StreamEventRecord] | :param response_id: The unique identifier of the response whose events to retrieve. :type response_id: str + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of deep-copied stream event records, or ``None`` if not found. :rtype: list[~azure.ai.agentserver.responses.models.runtime.StreamEventRecord] | None """ async with self._locked(): - entry = self._entries.get(response_id) + entry = self._entries.get(_store_key(response_id, context)) if entry is None: return None cutoff = datetime.now(timezone.utc) - timedelta(seconds=entry.replay_event_ttl_seconds) live = [e for e in entry.replay.events if e.emitted_at >= cutoff] return deepcopy(live) - async def delete(self, response_id: str) -> bool: + async def delete(self, response_id: str, *, context: PlatformContext | None = None) -> bool: """Delete all state for a response ID if present. Removes the entry entirely from the store (unlike ``delete_response`` @@ -526,15 +582,18 @@ async def delete(self, response_id: str) -> bool: :param response_id: The unique identifier of the response to remove. :type response_id: str + :keyword context: Platform context for partitioning; omitted context selects anonymous state. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: ``True`` if an entry was found and removed, ``False`` otherwise. :rtype: bool """ async with self._locked(): - self._stream_events.pop(response_id, None) - return self._entries.pop(response_id, None) is not None + key = _store_key(response_id, context) + self._stream_events.pop(key, None) + return self._entries.pop(key, None) is not None async def purge_expired(self, *, now: datetime | None = None) -> int: - """Remove expired entries and return count. + """Remove expired entries across all partitions and return count. :keyword ~datetime.datetime or None now: Optional override for the current time (useful for testing). :returns: The number of expired entries that were removed. @@ -579,33 +638,37 @@ def _purge_expired_unlocked(self, *, now: datetime | None = None) -> int: :rtype: int """ current_time = now or datetime.now(timezone.utc) - expired_ids = [ - response_id - for response_id, entry in self._entries.items() + expired_keys = [ + key + for key, entry in self._entries.items() if entry.expires_at is not None and entry.expires_at <= current_time ] - for response_id in expired_ids: - del self._entries[response_id] - self._stream_events.pop(response_id, None) + for key in expired_keys: + del self._entries[key] + self._stream_events.pop(key, None) # Prune orphaned stream events that have no corresponding entry. # Legacy bookkeeping — kept structurally so the in-memory provider # still tracks its expiration loop unchanged. Stream events are # now persisted by the SDK ``streams`` registry, not here. - orphaned_ids = [rid for rid in self._stream_events if rid not in self._entries] - for rid in orphaned_ids: - del self._stream_events[rid] + orphaned_keys = [key for key in self._stream_events if key not in self._entries] + for key in orphaned_keys: + del self._stream_events[key] - return len(expired_ids) + return len(expired_keys) - def _store_output_items_unlocked(self, response: _generated_models.ResponseObject) -> list[str]: + def _store_output_items_unlocked( + self, response: _generated_models.ResponseObject, *, context: PlatformContext | None + ) -> list[str]: """Extract output items from a response, store them in the item store, and return their IDs. Must be called while holding ``self._lock``. :param response: The response envelope whose output items should be stored. :type response: ~azure.ai.agentserver.responses.models._generated.Response + :keyword context: Platform context for partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: Ordered list of output item IDs. :rtype: list[str] """ @@ -616,7 +679,7 @@ def _store_output_items_unlocked(self, response: _generated_models.ResponseObjec for item in output: item_id = self._extract_item_id(item) if item_id is not None: - self._item_store[item_id] = deepcopy(item) + self._item_store[_store_key(item_id, context)] = deepcopy(item) output_ids.append(item_id) return output_ids diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/_sse.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/_sse.py index 4b58c2c519f5..9c46dcbf60b6 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/_sse.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/_sse.py @@ -233,6 +233,8 @@ async def _pump() -> None: try: async for item in source: queue.put_nowait(item) + except asyncio.CancelledError as exc: + pump_error = exc except Exception as exc: # pylint: disable=broad-exception-caught pump_error = exc finally: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/streaming-internals.md b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/streaming-internals.md index a54aa3b074b7..d9c1ad60747a 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/streaming-internals.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/streaming-internals.md @@ -41,13 +41,22 @@ Why these choices: ## Persistence file layout When the host is configured with `resilient_background=True`, the -file-backed backing writes one JSONL file per response under the +file-backed backing writes one JSONL file per caller-scoped lifecycle ID under the configured `storage_dir`: ```text -/.jsonl +/.jsonl ``` +The host derives `lifecycle_id` using +`derive_lifecycle_id(response_id, user_id_key)`. For identified users this is +`lifecycle-` followed by the SHA-256 digest of the user key and public response +ID, so different users' replay logs cannot collide. The public response ID in +HTTP paths and SSE payloads does not change. Anonymous requests (`user_id_key` +is `None`) retain the original response ID as their lifecycle key and keep the +previous file naming convention. Identified users never adopt those shared +legacy replay logs. + Each line is a single JSON object of the form `{"emit_time": , "payload": }`, ending with a terminator record `{"emit_time": , "__terminal__": true}` once @@ -63,9 +72,17 @@ subdirectories (`tasks/`, `streams/`, `responses/`). ## Recovery on restart -A fresh process that calls `await streams.get_or_create(response_id)` -for a `response_id` whose `.jsonl` file already exists on disk -rehydrates the stream from the persisted events automatically: +A fresh process looks up replay using the same caller-scoped lifecycle ID: + +```python +lifecycle_id = derive_lifecycle_id(response_id, user_id_key) +stream = await streams.get(lifecycle_id) +``` + +`get` rehydrates an existing `.jsonl` file from persisted events, but never +creates a file for an absent ID. Missing or expired replay raises +`EventStreamNotFoundError`. `get_or_create(lifecycle_id)` is reserved for +admitted execution that owns the producer lifecycle, not GET replay lookup. - Buffered events become available to new subscribers immediately. - `await stream.last_cursor()` returns the highest `sequence_number` @@ -76,8 +93,109 @@ rehydrates the stream from the persisted events automatically: If the previous run finished cleanly (terminator on disk) AND every persisted event has since expired, the rehydrated stream is in the -`GONE` state. Calling `streams.delete(id)` + `streams.get_or_create(id)` -mints a fresh stream. +`GONE` state; `streams.get(lifecycle_id)` cleans up and reports it as missing. +Only a newly admitted producer may use `streams.get_or_create(lifecycle_id)` +to mint a fresh stream. + +An empty ACTIVE file can remain after a crash before durable admission. Under +the caller-scoped create reservation, the host removes it only after confirming +that no live or pending execution, stored response, or resumable durable input +owns it. Storage failures or unavailable durable ownership lookup fail closed; +an empty cursor alone does not authorize reclamation. + +Durable ownership lookup enumerates all matching tasks, not just the newest +listing page. Providers perform complete no-limit enumeration and retain control +of their continuation cursors. A failed or incomplete scan never proves absence +of an owner or permits DELETE to complete. + +The provisional DELETE reservation excludes fresh admission, unrelated +publication, and eviction while visibility and status are checked. The current +execution's exact record or same non-null request context may still finalize +during these checks: a rejected in-flight DELETE must not suppress terminal +events or persistence. Once DELETE retains the exact authorized record for +cleanup, all execution publication is rejected, including that owning context. +If finalization replaces the record before cleanup retains it, the old record +cannot claim the replacement and DELETE must retry. + +DELETE retains exact caller-scoped ownership until replay and response-provider +cleanup succeed, including across cleanup errors and cancellation. Before +removing backing state, it conditionally fences that response's lifecycle input +in the durable task payload. Other turns and queued inputs are retained. +Recovery observes the fence before writes or admission and participates in the +same scoped reservations, so deleting a response cannot resurrect it on restart. + +Retained cleanup ownership is not a live execution or evidence of replay +capability. GET uses the authorized provider fallback and a noncreating replay +lookup for such records; a failed provider DELETE cannot recreate removed replay. + +Each newly admitted durable input carries a server-generated private +`response_incarnation_id`. DELETE retains fences for old incarnations rather +than clearing a response-wide fence on reuse. The task primitive's conditional +resume writes the new incarnation with its input, and recovery validates that +exact persisted incarnation under scoped admission before it writes or runs. +This permits DELETE followed by a same-ID POST in the same conversation without +allowing an old recovered turn to reuse the new execution's references or +reservation. Legacy inputs without an incarnation retain fail-closed deletion +checks. Public HTTP/SSE response IDs remain the original `response_id`. +Identified callers already use `derive_lifecycle_id` for task IDs, chain +`input_id` / `if_last_input_id` values, and replay filenames; anonymous callers +retain the legacy identifier layout. The private incarnation does not further +change these already caller-scoped task/replay keys. It appears only in the +durable input and private reference/deletion-fence keys. + +An invalid incarnation is never re-invoked. Only an exact match to the current +persisted input, without a deletion fence and under scoped recovery admission, +may reach fail-closed response settlement. Deleted or stale malformed callbacks +cannot create a failure marker for a removed or replacement response. + +The durable `user_id_key` is validated as `str | None` before platform-context +construction or lifecycle/runtime key derivation. Empty strings remain identified +partitions; missing/`None` legacy values remain anonymous. Lists, mappings, +numbers, and booleans are rejected, never coerced into the anonymous partition. +If this identity is corrupt, a response can be settled only using its original +process-local context and matching task-run, input-ID, and incarnation references, +with the exact current-input and deletion checks still applied. Neither opaque +task/input hashes nor a persisted call ID independently recover the missing user +partition. Without that proof, the handler is not invoked and only the malformed +task input is settled with a warning: existing response envelopes (possibly still +`in_progress`), replay, and runtime references are left untouched. Operator repair +or an authenticated caller operation is needed to address such orphaned storage. + +Recovered admission retires an exact stopped nonterminal runtime record only +after all lifecycle guards pass, under the reservation lock. Early settlement +and cancellation therefore cannot leave a stale live record shadowing provider +state. Active, terminal, foreign-user, and deletion-owned records are preserved. + +Shutdown closes fresh and recovered reservations atomically with its snapshot of +published and pending executions. A reservation alone does not admit execution: +first publication or pending registration must precede that snapshot. Afterward, +only the exact accepted record or its same-context finalization may publish, with +the existing predecessor and deletion-ownership guards still enforced. +Non-stored handlers are tracked as pending work before their first invocation. +Non-stream startup carries a temporary request-task drain handle until the +execution task takes ownership, so shutdown can signal and await accepted work +even before the first handler event. Exact pending cleanup cannot discard a +successor or another user's record. + +The response drain signals the accepted context's durable task shutdown event +before waiting for its execution task. Task-context attachment also preserves +shutdown already signaled during startup. This satisfies the Core recovery-exit +precondition while the response drain precedes TaskManager shutdown; explicit +and implicit exits retain the checkpoint and recoverable task input rather than +failing the task. Recovery exit outside shutdown still fails. + +Cold reconstruction binds the execution record, response context, and handler +to one cancellation event before runtime publication. A real client cancel +therefore wakes the recovered handler before cancellation waits for its tracked +execution and persists the cancelled terminal, rather than waiting for natural +completion on an unrelated event. Cancellation remains distinct from shutdown. + +A create rejected before HTTP headers returns the existing `503` +`service_unavailable` shutdown response, not a response-ID conflict or handler +failure. Once streaming headers have been sent, rejected startup retains the +existing connection-only `server_error` SSE contract. Newly allocated replay is +removed before the create reservation is released; retained replay and accepted +execution ownership are preserved. ## HTTP / SSE wire mapping @@ -87,16 +205,16 @@ The responses host exposes events through Server-Sent-Events on: layer subscribes to the per-response stream and yields each emit as an SSE event. - `GET /responses/{id}?stream=true` — **replay**. The endpoint looks up - the per-response stream from the registry and iterates its buffered - history. + the persisted response using the caller's user partition before calling + `streams.get(lifecycle_id)` and iterating buffered history. Storage errors + fail closed rather than falling through to cached replay. - Cursored reconnect: the SSE `Last-Event-ID: N` header (or the `?starting_after=N` query alias retained for backward compatibility) is forwarded as `stream.subscribe(after=N)`. - - When no stream exists for `id` (never registered, or destroyed via - `DELETE /responses/{id}`), the endpoint returns HTTP `404`. The - underlying registry exceptions - (`EventStreamNotFoundError` / `EventStreamGoneError`) both map to - `404` on this endpoint. + - A missing or unauthorized response returns HTTP `404`. If an authorized + background response exists but its replay is absent or expired, the + endpoint returns HTTP `400` with an invalid-mode error. The registry's + `EventStreamNotFoundError` never causes a lookup to create a new replay log. ## Other modules in this sub-package diff --git a/sdk/agentserver/azure-ai-agentserver-responses/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-responses/dev_requirements.txt index c7cc36e377fa..4051fa220965 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/dev_requirements.txt +++ b/sdk/agentserver/azure-ai-agentserver-responses/dev_requirements.txt @@ -1,5 +1,6 @@ -e ../../../eng/tools/azure-sdk-tools -e ../azure-ai-agentserver-core +-e ../azure-ai-agentserver-invocations ../../monitor/azure-monitor-query azure-identity>=1.17.0,<2.0.0 httpx>=0.27.0,<1.0.0 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md b/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md index 5563aa9d26d1..227b98dcf0cc 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md @@ -1297,14 +1297,14 @@ Handlers that do not interact with an LLM typically omit usage. |--------|---------|-------------| | `default_model` | `None` | Default model when `model` is omitted from the request | | `default_fetch_history_count` | `-1` | Maximum history items resolved by `get_history()`; `-1` fetches all history | -| `sse_keep_alive_interval_seconds` | `None` (disabled) | Interval between SSE keep-alive comments | +| `sse_keep_alive_interval_seconds` | `None` (inherits from `AgentConfig`) | Interval between SSE keep-alive comments | | `shutdown_grace_period_seconds` | `10` | Seconds to wait for in-flight requests on shutdown | Platform environment variables (read once at startup via `AgentConfig`): | Variable | Default | Description | |----------|---------|-------------| -| `SSE_KEEPALIVE_INTERVAL` | Disabled | Interval (seconds) between SSE keep-alive comments | +| `SSE_KEEPALIVE_INTERVAL` | `15` | Interval (seconds) between SSE keep-alive comments; `0` disables keep-alive when no explicit option overrides it | | `PORT` | `8088` | HTTP listen port | | `DEFAULT_FETCH_HISTORY_ITEM_COUNT` | `-1` | Override for `default_fetch_history_count` when using `ResponsesServerOptions.from_env()` | | `FOUNDRY_PROJECT_ENDPOINT` | — | Foundry project endpoint (enables persistence) | @@ -1332,8 +1332,11 @@ The library sets baggage items on the span: ### SSE Keep-Alive -The server can send periodic keep-alive comments during SSE streaming to prevent -reverse proxies from closing idle connections. Disabled by default. +The server sends periodic keep-alive comments during SSE streaming to prevent +reverse proxies from closing idle connections. When +`sse_keep_alive_interval_seconds` is unset, the host inherits the interval from +`AgentConfig`, which defaults to 15 seconds. Set `SSE_KEEPALIVE_INTERVAL=0` to +disable keep-alive comments when no explicit option overrides it. Enable via environment variable: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/docs/resilience-contract.md b/sdk/agentserver/azure-ai-agentserver-responses/docs/resilience-contract.md index ea7f2de9f901..43cef228bb0d 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/docs/resilience-contract.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/docs/resilience-contract.md @@ -63,12 +63,30 @@ Three boolean flags on the request select the resilience shape: delivered as SSE events on the original connection. Independent of the resilience shape; see the **Streaming sub-contract**. -### Server option +### Server options - **`resilient_background`** *(server option, default `False`)* — whether the framework engages full crash-recovery for `background=true, store=true` requests. When `True`, the supporting providers MUST be present (see **Composition rules**); the server fails loud at startup otherwise. +- **`steerable_conversations`** *(server option, default `False`)* — whether + stored conversation turns use the persistent queue/cancel steering runtime. + +The Responses host MUST request protocol-scoped `TaskManager` access when +either option is `True`. It MUST NOT read or mutate Core's process-wide +`set_resilient_tasks_enabled` switch: that switch remains the opt-in for direct +Core tasks and Invocations, and MUST NOT force Responses onto its task runtime +when both Responses options are `False`. Access is scoped so enabling one +protocol does not activate another protocol in the same process. + +The four option combinations are: + +- both `False`: no Responses task runtime; stored work executes non-durably; +- resilience only: crash-reinvoke stored background handlers, without steering; +- steering only: persistent queue/cancel; crash-fail the interrupted response + without re-running its handler, while persisted queued inputs MAY promote as + new steered turns after recovery; +- both `True`: full crash recovery plus steering. ### Termination paths @@ -84,19 +102,20 @@ per path. contract, and respond to waiting clients in this lifetime. - **Path C — crash, or a graceful shutdown whose Path-B action did not run** (SIGKILL, OOM, power loss, a hang during the shutdown loop). On the next - process lifetime the framework scans persisted state and applies the row's - restart contract. Path C is the complete fallback for Path B. + process lifetime an enabled task runtime scans persisted state and applies + the row's restart contract. Without a task runtime there is no Path-C cleanup. A single termination event is handled by exactly one path. ### Resilient record -Every accepted `store=true` request is registered with the underlying -resilient-task primitive at acceptance time. The registration carries the -response id, the row's Path-C disposition (`re-invoke` for Row 1, -`mark-failed` for Rows 2 and 3), and (for re-invocation rows) the handler -reference. `store=false` requests have no resilient record; Path C does not -apply. +When the Responses task runtime is active, every accepted `store=true` request +is registered with the underlying task primitive at acceptance time. The +registration carries the response id, the row's Path-C disposition +(`re-invoke` for Row 1, `mark-failed` for Rows 2 and 3), and (for +re-invocation rows) the handler reference. When both server options are +`False`, no such record is created and stored work runs non-durably. +`store=false` requests never have a task record; Path C does not apply. ### Recovered entry @@ -150,7 +169,14 @@ per-row contract. | 11 | `true` | `true` | `True` | all phases checkpoint + complete; final `response.output` reflects every phase | handler at a checkpoint boundary calls `await context.exit_for_recovery()`; recovery resumes from the last checkpointed snapshot | SIGKILL at a checkpoint boundary; recovery resumes from the last checkpointed snapshot | Read every cell as a MUST for the framework. Path A is identical across Rows -1–4 because no framework intervention is needed. +1–4 because no framework intervention is needed. Rows 2 and 3 Path C cells +apply only when the Responses task runtime is active: Row 2 therefore requires +`steerable_conversations=True`, while Row 3 requires either +`resilient_background=True` or `steerable_conversations=True`. If both server +options are `False`, ordinary stored work is non-durable and has no Path-C +action. Path B still best-effort persists the failed response during graceful +shutdown even without a task runtime. The request matrix and its +`resilient_background` column remain otherwise unchanged. --- @@ -184,7 +210,10 @@ with `context.is_recovery == True`. ### Row 2 — Marked failed (`store=true, background=true, resilient_background=False`) -A stored, observable response without crash recovery. +A stored, observable response without handler crash recovery. This row's +manager-backed Path B/C contract applies when +`steerable_conversations=True`. With both server options `False`, the response +runs non-durably and there is no next-lifetime action. **Path A.** Handler completes within grace. Standard. @@ -206,8 +235,10 @@ from the persisted task input. A stored response observable over the original (foreground) HTTP connection. `resilient_background` is a free axis — foreground responses do not benefit from resilient handler recovery because the client connection is gone. Path A/B/C -have the same shape as Row 2; all failure markers use `code=server_error` with -the path-specific cause in `message`. +have the same shape as Row 2 when either server option has requested the task +runtime; all failure markers use `code=server_error` with the path-specific +cause in `message`. When both options are `False`, the response is non-durable +and no restart action runs. ### Row 4 — Best-effort (`store=false`, any `background`, any `resilient_background`) @@ -334,6 +365,13 @@ absent; it MUST NOT silently downgrade to a weaker row. | `store=true` requests accepted (any row) | `ResponseStore` | Startup error | | `stream=true` requests accepted (any row) | A streaming-capable transport configuration | Startup error | +`steerable_conversations=True` alone requests the normal persistent task +backend for queue/cancel behavior, but MUST NOT broaden the +`resilient_background=True` provider guard or select file-backed SSE replay. +SSE replay remains file-backed only for `resilient_background=True` and +in-memory otherwise. Likewise, checkpoint writes and shutdown preservation +remain gated by `resilient_background && store && background`. + The same "fail loud, never silently downgrade" rule applies at **request time**. When `resilient_background` is in effect and the resilient task cannot be started, the framework MUST fail the request rather than silently running diff --git a/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md b/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md index e2b4b01b2793..f12bf300a528 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/docs/resilient-responses-developer-guide.md @@ -22,12 +22,15 @@ The *handler* half — making the recovered attempt resume *where it left off* and not repeat non-idempotent side effects — is optional work you take on when you want it; see [Choosing a resume strategy](#choosing-a-resume-strategy). -> **Default**: `resilient_background` defaults to `False`. Without the -> opt-in, a crash mid-handler leaves the response in the -> "crash-failed" state: the next-lifetime recovery scanner marks it -> `failed` (`error.code="server_error"`) instead -> of re-invoking the handler. Set `resilient_background=True` on -> `ResponsesServerOptions` to engage the re-invoke recovery path. +> **Defaults and activation:** both `resilient_background` and +> `steerable_conversations` default to `False`. Responses requests its +> protocol-scoped task runtime when either option is `True`; it does not read or +> mutate Core's process-wide `set_resilient_tasks_enabled` switch. With both +> options `False`, stored work runs non-durably and there is no next-lifetime +> mark-failed or handler re-invocation. With steering alone, the interrupted +> response is marked `failed` (`error.code="server_error"`) without re-running +> its handler, while persisted queued inputs may be promoted and started as new +> steered turns after recovery. ## What the Framework Provides (Zero Code) @@ -36,7 +39,7 @@ you want it; see [Choosing a resume strategy](#choosing-a-resume-strategy). | Crash recovery | Handler re-invoked on server restart (requires `resilient_background=True`) | | Stream replay | Events persisted incrementally; clients reconnect seamlessly | | Conversation lock | Prevents conflicting concurrent writes | -| Non-bg cleanup | Foreground responses marked `failed` on crash (no ghost re-invocation) | +| Non-bg cleanup | With the Responses task runtime active, foreground responses are marked `failed` on crash (no ghost re-invocation) | | TTL-based cleanup | Stream events auto-expire after 10 minutes (framework-internal) | ## Decision Tree @@ -130,36 +133,54 @@ mechanics. | Option | Default | Description | |--------|---------|-------------| -| `resilient_background` | `False` | Opt INTO crash-recoverable background responses | -| `steerable_conversations` | `False` | Enable multi-turn steering with cooperative cancel | +| `resilient_background` | `False` | Opt INTO crash-recoverable background responses and request the Responses task runtime | +| `steerable_conversations` | `False` | Request the Responses task runtime and enable persistent multi-turn queue/cancel steering | + +Responses activation is scoped to this protocol. Direct Core task APIs and the +Invocations protocol continue to use +`set_resilient_tasks_enabled(True)` before host startup. Conversely, that +process-wide setter cannot activate Responses when both options above are +`False`, so one protocol cannot accidentally enable a disabled protocol in a +mixed host. ## Configuration Matrix -Recovery semantics depend on three request flags and one server option. The -table below is a quick orientation. For the **normative** specification — the -exact behaviour you can rely on per row, per termination path, and per -stream/poll mode — see +Recovery semantics depend on three request flags and two server options. The +table keeps the existing `resilient_background` row axis; the text below it +states when `steerable_conversations` supplies the manager required for Rows 2 +and 3. For the **normative** specification — the exact behaviour you can rely +on per row, per termination path, and per stream/poll mode — see [`responses-resilience-spec.md`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md). That document is the source of truth; this section summarises it for developer ergonomics. | `store` | `background` | `resilient_background` | Summary | |---|---|---|---| | `true` | `true` | `True` | **Full recovery.** Handler is re-invoked with `context.is_recovery == True`. Persisted events replay to reconnecting clients. See [Crash Recovery](#crash-recovery). | -| `true` | `true` | `False` (default) | **Failed marker.** Response is marked `failed` on restart. Handler is NOT re-invoked. Pre-crash persisted events remain replayable until TTL expires. | -| `true` | `false` (foreground) | any | **Failed marker.** Response is marked `failed` with `code=server_error`. Handler is NOT re-invoked (the client's HTTP connection is already dead). Persisted events remain queryable. | +| `true` | `true` | `False` (default) | **Manager-dependent.** With `steerable_conversations=True`, the interrupted response is marked `failed` on restart and its handler is NOT re-invoked; persisted queued turns may still start as new steered turns. With both server options `False`, execution is non-durable and no restart action runs. | +| `true` | `false` (foreground) | any | **Never re-invoked.** When either server option requests the task runtime, the response is marked `failed` with `code=server_error`; with both options `False`, execution is non-durable and no restart action runs. Persisted response data remains queryable. | | `false` | any | any | **Best-effort failed marker** during shutdown grace period only. No persistence. Recovery does not apply. | -Each row × termination-path cell — Path A (handler completes within grace), -Path B (grace exhausted, in-process marker fires), Path C (crash or Path-B -failure, next-lifetime recovery fires) — is covered by a dedicated -conformance test in `tests/e2e/resilience_contract/`. If something behaves +Each manager-backed row × termination-path cell — Path A (handler completes +within grace), Path B (grace exhausted, in-process marker fires), Path C (crash +or Path-B failure, next-lifetime recovery fires) — is covered by the +conformance suite in `tests/e2e/resilience_contract/`. If something behaves differently from what the spec says, that's a bug in either the implementation or the spec — open an issue. `steerable_conversations=True` composes orthogonally: it enables multi-turn -steering on top of any row above. Recovery composes with steering — see the +steering on top of any row above and requests the normal persistent task +backend even when `resilient_background=False`. In steering-only mode, live +queue/cancel works, an interrupted current response is crash-failed without +handler re-invocation, and persisted queued turns may promote after restart. +Recovery composes with steering — see the [handler guide's Recovery × Cancellation Composition](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md#recovery--cancellation-composition). +These server options do not change the request-level mode contract: +`background=true, store=false` is still rejected with HTTP 400; ordinary +`store=false` foreground work remains ephemeral; a foreground client +disconnect cancels foreground work; and accepted background work survives a +client disconnect. + > **`conversation_id` chains**: when a request supplies > `conversation_id`, sequential turns extend the chain even when > `steerable_conversations=False`. Only **concurrent overlap** (a new @@ -204,12 +225,15 @@ restarts. The framework defaults provide this automatically; the sections below describe what they do and how to override them for specific scenarios. -- **Resilient task store**: in a hosted environment the framework uses +- **Task store**: when either server option requests the Responses task runtime, + a hosted environment uses the Foundry task storage API; in local development it auto-selects a file-backed task store under - `${AGENTSERVER_STATE_ROOT:-~/.agentserver}/tasks/`. Either way, tasks - survive process restarts so a recovered handler re-enters its prior - task body. Operators can override the auto-selection by setting + `${AGENTSERVER_STATE_ROOT:-~/.agentserver}/tasks/`. With + `resilient_background=True`, recovered background handlers re-enter their + prior task body. With steering alone, the interrupted handler is not re-run, + but persisted queued turns may start as new steered turns. Operators can + override the auto-selection by setting `AGENTSERVER_TASKS_BACKEND=local` (to force file-backed in hosted) or `AGENTSERVER_TASKS_BACKEND=hosted` (to force the hosted API in local). @@ -236,10 +260,11 @@ owns the equivalent migration for its task store). ## Recovery + steering surface on `ResponseContext` -When `resilient_background=True`, the framework populates flat fields -on the response context for every handler invocation. The fields -mirror the underlying task primitive's classifiers and are safe to -read regardless of `is_recovery`: +When either `resilient_background=True` or +`steerable_conversations=True`, the framework populates flat task-classifier +fields on the response context for every handler invocation. They are safe to +read regardless of `is_recovery`; recovery-specific values become meaningful +only on the resilient re-invocation path: > **Recovered inputs are identical to fresh entry.** On a recovered > re-invocation the handler observes the *same* `request`, `client_headers`, @@ -570,8 +595,12 @@ final response but may render intermediate states incorrectly. When `background=false` (foreground streaming): - Response is tied to the HTTP connection lifetime. -- If the server crashes: response is marked `failed` with `code=server_error`. -- The handler is NOT re-invoked (client is already disconnected). +- The handler is never re-invoked after a crash, regardless of + `resilient_background`. +- When either server option has requested the task runtime, a crash marks the + response `failed` with `code=server_error`. +- When both options are `False`, the response runs non-durably and no + next-lifetime mark-failed action occurs. - Conversation lock still applies (prevents concurrent modifications). ## Layered Concerns diff --git a/sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md b/sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md index 57a07021a2cb..6192585983f3 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md @@ -340,9 +340,18 @@ history-id lookup; it is a prefetch optimization, not recovery state. The boundary is **fail-closed**: the object is JSON-serializable by construction (no runtime object references — those live in a separate -process-local cache keyed by `response_id` and are never serialized), and a -malformed/incomplete persisted input fails the recovered task deterministically -rather than re-invoking the handler with partial state. +process-local cache keyed by caller-scoped response incarnation and are never +serialized), and malformed/incomplete persisted input is never re-invoked with +partial state. `user_id_key` MUST be `str | None` before platform-context or key +construction; an empty string is an identified partition, while missing/`None` +legacy values remain anonymous. Invalid values MUST NOT be converted to `None`. +When the partition is malformed, response settlement requires original +process-local references bound to the matching task-run, input ID, and private +incarnation, plus current-input/deletion checks. Task IDs and input hashes alone +cannot recover the user partition. Without independent proof, only the malformed +input is settled, with an explicit warning and no response/replay/reference +mutation; an existing envelope may remain `in_progress` pending operator repair +or an authenticated caller action. > **Port note.** Oversized input (e.g. a large input-item array) rides the core > resilient-task primitive's attachment-spill — the responses layer does not shard diff --git a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml index 430698b5c7ff..3de96881c6a6 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" license = "MIT" authors = [{ name = "Microsoft Corporation" }] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "azure-ai-agentserver-core>=2.2.0,<3.0.0", + "azure-ai-agentserver-core>=2.4.0b1,<3.0.0", "azure-core>=1.37.0,<2.0.0", "isodate>=0.6.1,<1.0.0", "aiohttp>=3.10.0,<4.0.0a0", @@ -76,4 +76,5 @@ in_bundle = false [tool.azure-sdk-build] verifytypes = false latestdependency = false -mindependency = true +# The shared experimental decorator and snapshot task integration require the matching Core preview. +mindependency = false diff --git a/sdk/agentserver/azure-ai-agentserver-responses/samples/README.md b/sdk/agentserver/azure-ai-agentserver-responses/samples/README.md index 44275d72acff..98645b48cb0b 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/samples/README.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/samples/README.md @@ -65,8 +65,20 @@ app = ResponsesAgentServerHost( ) ``` -Without `resilient_background=True`, a crash mid-handler leaves the -response in the "crash-failed" state (the next process lifetime marks -it `failed` instead of re-invoking the handler). Without -`steerable_conversations=True`, concurrent multi-turn requests for the -same conversation return `409 conversation_locked` instead of queueing. \ No newline at end of file +Either option initializes the Responses task runtime automatically; no +`set_resilient_tasks_enabled` call is needed. That switch controls Invocations, +not Responses. + +With `steerable_conversations=True` and `resilient_background=False`, steering +uses the normal persistent task backend. On restart, an interrupted stored +response is marked `failed` without re-invoking its handler; persisted queued +turns may still be promoted and executed. + +With both options `False`, there is no Responses task runtime or task-based +crash cleanup. An interrupted stored response can remain `in_progress` after a +hard crash. Graceful shutdown still attempts to persist a failed terminal. + +When the task runtime is active, concurrent requests for the same explicit +conversation queue with steering enabled or receive `409 conversation_locked` +with steering disabled. Without a task runtime, overlapping requests execute +independently rather than acquiring a task-backed conversation lock. \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_19_resilient_streaming.py b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_19_resilient_streaming.py index 864ff2b26c6a..660281afce94 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_19_resilient_streaming.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_19_resilient_streaming.py @@ -62,17 +62,10 @@ ResponsesAgentServerHost, ResponsesServerOptions, ) -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled options = ResponsesServerOptions(resilient_background=True) app = ResponsesAgentServerHost(options=options) -# Explicitly opt into resilient-task startup recovery, for parity with the -# invocations resilient samples. The Responses framework already registers its -# internal durable tasks at host construction (so recovery runs regardless); -# this call just makes the opt-in intent explicit. -set_resilient_tasks_enabled(True) - _SIMULATE_SHUTDOWN_MS = int(os.environ.get("SIMULATE_SHUTDOWN_MS", "0")) # Phases run in order. Each emits one message output item and is made diff --git a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_20_resilient_steering.py b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_20_resilient_steering.py index b89622d2dc0d..78283753b915 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_20_resilient_steering.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_20_resilient_steering.py @@ -60,7 +60,6 @@ import os from azure.ai.agentserver.core.storage import FoundryStateStore -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled from azure.ai.agentserver.responses import ( CreateResponse, ResponseContext, @@ -75,12 +74,6 @@ ) app = ResponsesAgentServerHost(options=options) -# Explicitly opt into resilient-task startup recovery, for parity with the -# invocations resilient samples. The Responses framework already registers its -# internal durable tasks at host construction (so recovery runs regardless); -# this call just makes the opt-in intent explicit. -set_resilient_tasks_enabled(True) - _SIMULATE_SHUTDOWN_MS = int(os.environ.get("SIMULATE_SHUTDOWN_MS", "0")) @@ -136,11 +129,7 @@ async def handler( ) async with store: item = await store.get_item("state") - state = ( - dict(item.value) - if item is not None and isinstance(item.value, dict) - else {} - ) + state = dict(item.value) if item is not None and isinstance(item.value, dict) else {} if state.get("last_response_id") == context.response_id: turn_count = int(state.get("turn_count", 1)) else: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_21_resilient_langgraph.py b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_21_resilient_langgraph.py index 70e99eda0d9b..a86cae90c068 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_21_resilient_langgraph.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_21_resilient_langgraph.py @@ -71,7 +71,6 @@ from langgraph.types import Command, interrupt from azure.ai.agentserver.core.storage import FoundryStateStore -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled from azure.ai.agentserver.responses import ( CreateResponse, ResponseContext, @@ -93,12 +92,8 @@ class ConversationState(typing.TypedDict): # ─── Graph nodes ──────────────────────────────────────────────────────────── -_STEP_DELAY = float( - os.environ.get("LANGGRAPH_STEP_DELAY_SEC", "0.4") -) # Seconds per non-streaming node. -_TOKEN_DELAY = float( - os.environ.get("LANGGRAPH_TOKEN_DELAY_SEC", "0.08") -) # Seconds between streamed tokens. +_STEP_DELAY = float(os.environ.get("LANGGRAPH_STEP_DELAY_SEC", "0.4")) # Seconds per non-streaming node. +_TOKEN_DELAY = float(os.environ.get("LANGGRAPH_TOKEN_DELAY_SEC", "0.08")) # Seconds between streamed tokens. async def analyze_input(state: ConversationState) -> dict[str, Any]: @@ -153,11 +148,7 @@ def _should_continue(state: ConversationState) -> str: # one is configured (so all of this deployment's durable state lives together # and survives restarts); fall back to a per-user dir for local runs. _STATE_ROOT = os.environ.get("AGENTSERVER_STATE_ROOT") -_DATA_DIR = ( - Path(_STATE_ROOT) / "langgraph" - if _STATE_ROOT - else Path.home() / ".agentserver" / "langgraph-responses" -) +_DATA_DIR = Path(_STATE_ROOT) / "langgraph" if _STATE_ROOT else Path.home() / ".agentserver" / "langgraph-responses" _DATA_DIR.mkdir(parents=True, exist_ok=True) _DB_PATH = _DATA_DIR / "checkpoints.db" @@ -177,9 +168,7 @@ def _build_graph(checkpointer: Any) -> Any: builder.add_edge("analyze_input", "generate_response") builder.add_edge("generate_response", "refine_response") builder.add_edge("refine_response", "wait_for_user") - builder.add_conditional_edges( - "wait_for_user", _should_continue, {"continue": "analyze_input", "end": END} - ) + builder.add_conditional_edges("wait_for_user", _should_continue, {"continue": "analyze_input", "end": END}) return builder.compile(checkpointer=checkpointer) @@ -208,12 +197,6 @@ async def _get_graph() -> Any: ) app = ResponsesAgentServerHost(options=options) -# Explicitly opt into resilient-task startup recovery, for parity with the -# invocations resilient samples. The Responses framework already registers its -# internal durable tasks at host construction (so recovery runs regardless); -# this call just makes the opt-in intent explicit. -set_resilient_tasks_enabled(True) - # Metadata key: the LangGraph checkpoint id whose completed work matches the # response items persisted so far. Recorded in ``internal_metadata`` (persisted # atomically WITH the items on every ``stream.checkpoint()``, stripped on @@ -232,14 +215,11 @@ def _reply_already_persisted(stream: ResponseEventStream) -> bool: again. It also becomes True the moment this attempt closes a fresh reply item. """ return any( - isinstance(item, dict) and item.get("type") == "message" - for item in (stream.response.get("output") or []) + isinstance(item, dict) and item.get("type") == "message" for item in (stream.response.get("output") or []) ) -async def _fork_from_checkpoint( - graph: Any, config: dict[str, Any], checkpoint_id: str, new_message: str -) -> bool: +async def _fork_from_checkpoint(graph: Any, config: dict[str, Any], checkpoint_id: str, new_message: str) -> bool: """Fork graph state from a stable checkpoint with a new (steered) message.""" # The sqlite checkpointer requires ``checkpoint_ns`` on the config it writes # through; the resilient context only carries ``thread_id``, so seed the @@ -299,9 +279,7 @@ async def handler( # Seed from the last framework checkpoint so already-emitted items keep their # ORIGINAL ids; the in_progress below re-emits them (the reset point). if context.is_recovery and context.persisted_response is not None: - stream = ResponseEventStream( - response_id=context.response_id, response=context.persisted_response - ) + stream = ResponseEventStream(response_id=context.response_id, response=context.persisted_response) else: stream = ResponseEventStream(response_id=context.response_id, request=request) @@ -328,16 +306,8 @@ async def handler( # checkpoint, so nodes after it (incl. the token-streaming node, if its # reply was not yet persisted) re-run — re-streaming the reply exactly # when needed and nothing more. - graph_cp = ( - stream.internal_metadata.get(_GRAPH_CP_KEY) - if context.persisted_response is not None - else None - ) - run_config = ( - {"configurable": {"thread_id": chain_id, "checkpoint_id": graph_cp}} - if graph_cp - else thread_config - ) + graph_cp = stream.internal_metadata.get(_GRAPH_CP_KEY) if context.persisted_response is not None else None + run_config = {"configurable": {"thread_id": chain_id, "checkpoint_id": graph_cp}} if graph_cp else thread_config graph_input: Any = None else: input_text = await context.get_input_text() @@ -347,11 +317,7 @@ async def handler( ) async with store: item = await store.get_item("state") - conversation_state = ( - dict(item.value) - if item is not None and isinstance(item.value, dict) - else {} - ) + conversation_state = dict(item.value) if item is not None and isinstance(item.value, dict) else {} stable_cp = conversation_state.get("stable_checkpoint_id") state = await graph.aget_state(thread_config) run_config = thread_config @@ -359,9 +325,7 @@ async def handler( if stable_cp and context.is_steered_turn: # Fresh steered successor: fork from the last stable checkpoint with # the new message, then resume (input=None). - forked = await _fork_from_checkpoint( - graph, thread_config, stable_cp, input_text - ) + forked = await _fork_from_checkpoint(graph, thread_config, stable_cp, input_text) graph_input = ( None if forked @@ -378,9 +342,7 @@ async def handler( # Graph left mid-turn (e.g. a prior turn was cancelled before its # interrupt) — do NOT Command(resume) a non-interrupt node; fork a # clean turn from the last stable checkpoint instead. - forked = await _fork_from_checkpoint( - graph, thread_config, stable_cp, input_text - ) + forked = await _fork_from_checkpoint(graph, thread_config, stable_cp, input_text) graph_input = ( None if forked @@ -427,9 +389,7 @@ async def handler( # is the point at which the reply is durable. saw_generate_commit = True elif mode == "checkpoints": - checkpoint_id = ( - chunk.get("config", {}).get("configurable", {}).get("checkpoint_id") - ) + checkpoint_id = chunk.get("config", {}).get("configurable", {}).get("checkpoint_id") if reply_open and not reply_closed and saw_generate_commit: yield text.emit_text_done() yield text.emit_done() diff --git a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_22_resilient_multiturn.py b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_22_resilient_multiturn.py index 88e9fcc2223e..45d8a90f9ee8 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_22_resilient_multiturn.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/samples/sample_22_resilient_multiturn.py @@ -38,7 +38,6 @@ import asyncio from azure.ai.agentserver.core.storage import FoundryStateStore -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled from azure.ai.agentserver.responses import ( CreateResponse, ResponseContext, @@ -53,12 +52,6 @@ ) app = ResponsesAgentServerHost(options=options) -# Explicitly opt into resilient-task startup recovery, for parity with the -# invocations resilient samples. The Responses framework already registers its -# internal durable tasks at host construction (so recovery runs regardless); -# this call just makes the opt-in intent explicit. -set_resilient_tasks_enabled(True) - @app.response_handler async def handler( @@ -74,15 +67,8 @@ async def handler( ) async with store: item = await store.get_item("state") - state = ( - dict(item.value) - if item is not None and isinstance(item.value, dict) - else {} - ) - if ( - state.get("terminated") - and state.get("last_response_id") != context.response_id - ): + state = dict(item.value) if item is not None and isinstance(item.value, dict) else {} + if state.get("terminated") and state.get("last_response_id") != context.response_id: state = {} if state.get("last_response_id") == context.response_id: turn_count = int(state.get("turn_count", 1)) @@ -91,9 +77,7 @@ async def handler( # Explicit session termination if input_text.strip().lower() == "done": - if state.get("last_response_id") == context.response_id and state.get( - "terminated" - ): + if state.get("last_response_id") == context.response_id and state.get("terminated"): completed_turns = int(state.get("completed_turns", 0)) else: completed_turns = max(turn_count - 1, 0) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/conftest.py index 84521fa950ae..166244e2c69f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/conftest.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/conftest.py @@ -30,14 +30,19 @@ def pytest_configure(config): ) +@pytest.fixture(autouse=True) +def _cold_start_snapshot_mode(monkeypatch): + """Keep ordinary tests independent of inherited capture-mode configuration.""" + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "false") + + @pytest.fixture(autouse=True) def _reset_resilient_tasks_global_state(): """Contain process-global resilient-tasks state per test. The resilient-tasks enable switch and the ``TaskManager`` singleton are - process-global. A test that enables resilient tasks — e.g. constructs a - ``resilient_background=True`` host, which auto-enables the switch, or calls - ``set_resilient_tasks_enabled(True)`` — would otherwise leak that state into + process-global compatibility state. A test that installs a task manager or + calls ``set_resilient_tasks_enabled(True)`` would otherwise leak state into subsequent tests in the same pytest process (a plain host would then build a manager and behave durably). Snapshot both at test start and restore at the end so each test is isolated. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_async_terminal_persist.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_async_terminal_persist.py index c4923dfcfa99..aaaa0c153ef2 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_async_terminal_persist.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_async_terminal_persist.py @@ -285,9 +285,9 @@ def _delay_fallback_task(coro: Any, *args: Any, **kwargs: Any) -> Any: await asyncio.wait_for(fallback_task, 5) await asyncio.wait_for(shutdown_task, 5) - assert not shutdown_completed_before_registration, ( - "shutdown completed before the already-created fallback task registered itself" - ) + assert ( + not shutdown_completed_before_registration + ), "shutdown completed before the already-created fallback task registered itself" finally: monkeypatch.setattr(asyncio, "create_task", original_create_task) release.set() @@ -329,7 +329,7 @@ async def _events(): json_body={"model": "m", "input": "hi", "stream": True, "store": True}, ) - assert response.status_code == 200 + assert response.status_code == 503 assert handler_called is False @pytest.mark.asyncio diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py index f43ee44a277f..2dfedff823dd 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py @@ -90,16 +90,18 @@ def test_delete_succeeds_when_eviction_races_with_delete(self, monkeypatch: pyte _original_try_evict = _RuntimeState.try_evict _original_delete = _RuntimeState.delete - async def _patched_try_evict(self: _RuntimeState, response_id: str) -> bool: + async def _patched_try_evict(self: _RuntimeState, response_id: str, user_id_key: str | None = None) -> bool: if _suppress_eviction: return False # Keep record in memory for the race setup - return await _original_try_evict(self, response_id) + return await _original_try_evict(self, response_id, user_id_key) - async def _racing_delete(self: _RuntimeState, response_id: str) -> bool: + async def _racing_delete( + self: _RuntimeState, response_id: str, user_id_key: str | None = None, **kwargs + ) -> bool: if _force_race: # Simulate the bg task's try_evict completing right now - await _original_try_evict(self, response_id) - return await _original_delete(self, response_id) + await _original_try_evict(self, response_id, user_id_key) + return await _original_delete(self, response_id, user_id_key, **kwargs) monkeypatch.setattr(_RuntimeState, "try_evict", _patched_try_evict) monkeypatch.setattr(_RuntimeState, "delete", _racing_delete) @@ -158,9 +160,9 @@ async def _recording_fallback(self_handler: Any, response_id: str, context: Any, _original_rs_get = RS.get _eviction_detected = False - async def _detecting_get(self_rs: Any, response_id: str) -> Any: + async def _detecting_get(self_rs: Any, response_id: str, user_id_key: str | None = None) -> Any: nonlocal _eviction_detected - result = await _original_rs_get(self_rs, response_id) + result = await _original_rs_get(self_rs, response_id, user_id_key) if result is None: _eviction_detected = True return result @@ -213,15 +215,17 @@ def test_second_delete_after_race_recovery_returns_404(self, monkeypatch: pytest _original_try_evict = _RuntimeState.try_evict _original_delete = _RuntimeState.delete - async def _patched_try_evict(self: _RuntimeState, response_id: str) -> bool: + async def _patched_try_evict(self: _RuntimeState, response_id: str, user_id_key: str | None = None) -> bool: if _suppress_eviction: return False - return await _original_try_evict(self, response_id) + return await _original_try_evict(self, response_id, user_id_key) - async def _racing_delete(self: _RuntimeState, response_id: str) -> bool: + async def _racing_delete( + self: _RuntimeState, response_id: str, user_id_key: str | None = None, **kwargs + ) -> bool: if _force_race: - await _original_try_evict(self, response_id) - return await _original_delete(self, response_id) + await _original_try_evict(self, response_id, user_id_key) + return await _original_delete(self, response_id, user_id_key, **kwargs) monkeypatch.setattr(_RuntimeState, "try_evict", _patched_try_evict) monkeypatch.setattr(_RuntimeState, "delete", _racing_delete) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_resilient_start_failure.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_resilient_start_failure.py index 7a71295dc280..32a9500d2ecf 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_resilient_start_failure.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_resilient_start_failure.py @@ -10,8 +10,7 @@ and surface a *platform* error source (like a Foundry storage failure). A real durability failure must not hide behind a healthy-looking response. -2. No task subsystem is installed at all (the host did not enable resilient - tasks via ``set_resilient_tasks_enabled``): this is the deliberate opt-out +2. Neither Responses runtime option is enabled: this is the deliberate opt-out path. ``TaskManagerNotInitialized`` is SWALLOWED and the handler runs in-process — the response still executes and persists (GET works), it is simply not crash-recoverable. This applies regardless of hosted vs local. @@ -33,8 +32,10 @@ set_resilient_tasks_enabled, ) from azure.ai.agentserver.core.tasks._manager import get_task_manager, set_task_manager -from azure.ai.agentserver.responses import ResponsesAgentServerHost +from azure.ai.agentserver.responses import ResponsesAgentServerHost, ResponsesServerOptions from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as _ro +from azure.ai.agentserver.responses.hosting._runtime_state import _RuntimeState +from azure.ai.agentserver.responses.models.runtime import ResponseExecution from azure.ai.agentserver.responses.streaming._event_stream import ResponseEventStream @@ -44,8 +45,8 @@ def _switch_off() -> Any: Both the enable switch and the ``TaskManager`` singleton are process-global and other tests in a shared pytest process may have flipped the switch on or - installed a manager (e.g. by constructing a ``resilient_background=True`` - host). Snapshot, reset both to the switch-off / no-manager state so the + installed a manager (e.g. by starting an enabled Responses host). + Snapshot, reset both to the switch-off / no-manager state so the production opt-out path is genuinely exercised, then restore afterwards. """ from azure.ai.agentserver.core.tasks import _manager as _mgr_mod # pylint: disable=import-outside-toplevel @@ -70,8 +71,10 @@ async def _events() -> AsyncIterator[Any]: return _events() -def _build_client(handler: Any = None) -> TestClient: - app = ResponsesAgentServerHost() +def _build_client(handler: Any = None, *, task_runtime: bool = False, resilient_background: bool = False) -> TestClient: + app = ResponsesAgentServerHost( + options=ResponsesServerOptions(steerable_conversations=task_runtime, resilient_background=resilient_background) + ) app.response_handler(handler or _noop_handler) return TestClient(app) @@ -110,7 +113,7 @@ class TestTaskStartFailureSurfacesPlatform: """Manager present + task start fails → fail immediately, platform source.""" def test_background_start_failure_returns_platform_500(self, _start_fails: None) -> None: - with _build_client() as client: # lifespan installs a real TaskManager + with _build_client(task_runtime=True) as client: # lifespan installs a real TaskManager resp = client.post( "/responses", json={"model": "test", "input": "hi", "stream": False, "store": True, "background": True}, @@ -122,7 +125,7 @@ def test_background_start_failure_returns_platform_500(self, _start_fails: None) assert "in_progress" not in resp.text def test_sync_start_failure_returns_platform_500(self, _start_fails: None) -> None: - with _build_client() as client: + with _build_client(task_runtime=True) as client: resp = client.post( "/responses", json={"model": "test", "input": "hi", "stream": False, "store": True, "background": False}, @@ -132,7 +135,7 @@ def test_sync_start_failure_returns_platform_500(self, _start_fails: None) -> No assert ERROR_DETAIL in resp.headers def test_streaming_start_failure_emits_standalone_error_event(self, _start_fails: None) -> None: - with _build_client() as client: + with _build_client(task_runtime=True) as client: resp = client.post( "/responses", json={"model": "test", "input": "hi", "stream": True, "store": True, "background": True}, @@ -144,11 +147,43 @@ def test_streaming_start_failure_emits_standalone_error_event(self, _start_fails assert "error" in types, f"expected a standalone error event, got: {types}" assert "response.completed" not in types, f"must not complete on start failure, got: {types}" + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resilient,steerable", [(False, True), (True, False), (True, True)]) + def test_closed_manager_never_falls_back_to_non_durable_execution( + self, _switch_off: Any, caplog: pytest.LogCaptureFixture, stream: bool, resilient: bool, steerable: bool + ) -> None: + called = [] + + async def handler(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + called.append(True) + return await _noop_handler(request, context, cancellation_signal) + + with _build_client(handler, task_runtime=steerable, resilient_background=resilient) as client: + manager = get_task_manager() + assert client.portal is not None + client.portal.call(manager._shutdown_event.set) + response = client.post( + "/responses", + json={"model": "test", "input": "hi", "stream": stream, "store": True}, + ) + if stream: + types = [event["type"] for event in _collect_sse_events(response.text)] + assert "error" in types + assert "response.completed" not in types + else: + assert response.status_code == 500, response.text + assert response.headers.get(ERROR_SOURCE) == "platform" + assert not called + assert any( + record.exc_info and "TaskManager is shutting down" in str(record.exc_info[1]) + for record in caplog.records + ) + class TestNoTaskManagerStillRunsHandler: """No task subsystem (opt-out) → handler runs in-process (non-durable).""" - def test_no_manager_background_runs_handler_ok(self) -> None: + def test_no_manager_background_runs_handler_ok(self, _switch_off: Any) -> None: # Plain TestClient (no `with` → no lifespan → no TaskManager installed) # → the outer catch swallows TaskManagerNotInitialized and runs the # handler in-process. Legitimate opt-out path, NOT a failure. @@ -220,3 +255,152 @@ def test_switch_off_no_manager_streaming_runs_in_process(self, _switch_off: Any) # In-process fallback runs the handler to completion; no error surface. assert "error" not in types, f"must not surface an error on opt-out, got: {types}" assert "response.completed" in types, f"expected completion, got: {types}" + + +class TestSnapshotTaskActivation: + """Task definitions stay process-side; requests follow session activation.""" + + def test_enabled_cold_start_installs_task_runtime(self, _switch_off: Any) -> None: + with _build_client(task_runtime=True): + assert get_task_manager() is not None + assert not resilient_tasks_enabled() + + @pytest.mark.parametrize("background", [False, True]) + @pytest.mark.parametrize("user_id", [None, "snapshot-user"]) + @pytest.mark.parametrize("resilient,steerable", [(False, True), (True, False), (True, True)]) + def test_deferred_start_failure_removes_scoped_runtime( + self, + _switch_off: Any, + monkeypatch: pytest.MonkeyPatch, + background: bool, + user_id: str | None, + resilient: bool, + steerable: bool, + ) -> None: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "true") + registered: list[tuple[_RuntimeState, ResponseExecution]] = [] + original_add = _RuntimeState.add + + async def observe_add( + state: _RuntimeState, + record: ResponseExecution, + *, + expected_record: ResponseExecution | None = None, + ) -> bool: + registered.append((state, record)) + return await original_add(state, record, expected_record=expected_record) + + monkeypatch.setattr(_RuntimeState, "add", observe_add) + called: list[bool] = [] + + async def handler(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + called.append(True) + return await _noop_handler(request, context, cancellation_signal) + + headers = {"x-agent-user-id": user_id} if user_id is not None else {} + with _build_client(handler, task_runtime=steerable, resilient_background=resilient) as http: + response = http.post( + "/responses", + headers=headers, + json={"model": "test", "input": "hi", "stream": False, "store": True, "background": background}, + ) + assert response.status_code == 500, response.text + assert response.headers.get(ERROR_SOURCE) == "platform" + assert not called + assert len(registered) == 1 + state, record = registered[0] + assert record.user_id_key == user_id + assert http.portal is not None + assert http.portal.call(state.get, record.response_id, user_id) is None + assert http.portal.call(state.is_deleted, record.response_id, user_id) + assert not http.portal.call(state.is_deleted, record.response_id, "unrelated-user") + assert http.get(f"/responses/{record.response_id}", headers=headers).status_code == 404 + + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("resilient,steerable", [(False, True), (True, False), (True, True)]) + def test_enabled_missing_runtime_does_not_fall_back( + self, _switch_off: Any, monkeypatch: pytest.MonkeyPatch, stream: bool, resilient: bool, steerable: bool + ) -> None: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "true") + called: list[bool] = [] + + async def handler(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + called.append(True) + return await _noop_handler(request, context, cancellation_signal) + + with _build_client(handler, task_runtime=steerable, resilient_background=resilient) as http: + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + response = http.post( + "/responses", + json={"model": "test", "input": "hi", "stream": stream, "store": True}, + ) + if stream: + types = [event["type"] for event in _collect_sse_events(response.text)] + assert "error" in types + assert "response.completed" not in types + else: + assert response.status_code == 500, response.text + assert response.headers.get(ERROR_SOURCE) == "platform" + assert not called + + @pytest.mark.parametrize("value", [None, "false"]) + @pytest.mark.parametrize("stream", [False, True]) + def test_cold_start_missing_runtime_keeps_in_process_fallback( + self, _switch_off: Any, monkeypatch: pytest.MonkeyPatch, value: str | None, stream: bool + ) -> None: + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + called: list[bool] = [] + + async def handler(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + called.append(True) + return await _noop_handler(request, context, cancellation_signal) + + # Plain TestClient (no `with`): the lifespan never runs, so no manager exists. + response = _build_client(handler, task_runtime=True).post( + "/responses", + json={"model": "test", "input": "hi", "stream": stream, "store": True}, + ) + assert response.status_code == 200, response.text + assert ERROR_SOURCE not in response.headers + if stream: + types = [event["type"] for event in _collect_sse_events(response.text)] + assert "error" not in types + assert "response.completed" in types + assert called + + def test_requests_after_restore_use_registered_task_runtime( + self, _switch_off: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_SESSION_ID", raising=False) + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "true") + with _build_client(task_runtime=True) as http: + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + restored = http.post( + "/_agent/after-restore", + json={ + "session_context": { + "session_id": "restored-session", + "restore_id": "restore-1", + "session_env_overrides": {}, + } + }, + ) + assert restored.status_code == 200, restored.text + manager = get_task_manager() + assert manager._config.session_id == "restored-session" + assert { + "responses_resilient_one_shot", + "responses_resilient_multi_turn", + }.issubset(manager._resume_callbacks) + response = http.post( + "/responses", + json={"model": "test", "input": "hi", "stream": False, "store": True}, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "completed" + assert ERROR_SOURCE not in response.headers diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py index 68534ef13b09..003f3603f561 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py @@ -16,16 +16,260 @@ from __future__ import annotations +import asyncio +import json +from importlib.metadata import requires, version from pathlib import Path from typing import Any +from urllib.parse import quote, unquote +import httpx +from openai import AsyncOpenAI, AsyncStream +import pytest +from packaging.requirements import Requirement +from packaging.version import Version from starlette.testclient import TestClient -from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + FoundryOriginalSubject, + get_request_context, + reset_request_context, + set_request_context, +) from azure.ai.agentserver.responses import ResponsesAgentServerHost, ResponsesServerOptions from azure.ai.agentserver.responses.store._file import FileResponseStore from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider from azure.ai.agentserver.responses.streaming import ResponseEventStream +from azure.ai.agentserver.responses.hosting._observability import InMemoryCreateSpanHook + + +def test_original_subject_and_snapshot_core_dependency_floor() -> None: + requirements = (Requirement(value) for value in requires("azure-ai-agentserver-responses") or []) + core = next(value for value in requirements if value.name == "azure-ai-agentserver-core") + package_version = Version(version("azure-ai-agentserver-responses")) + if package_version.pre and package_version.pre[0] == "a": + assert core.specifier.contains(Version(version("azure-ai-agentserver-core")), prereleases=True) + else: + assert "2.4.0b1" in core.specifier + assert "2.3.0" not in core.specifier + assert "2.3.0b3" not in core.specifier + assert "2.3.0b2" not in core.specifier + assert "2.3.0b1" not in core.specifier + assert callable(FoundryAgentRequestContext.model_attribution_headers) + + +@pytest.mark.parametrize("value", ["malformed", 'oid="one", oid="two"', 'unknown="value"']) +def test_malformed_original_subject_ends_span_without_running_handler(value: str) -> None: + hook = InMemoryCreateSpanHook() + app = ResponsesAgentServerHost(options=ResponsesServerOptions(create_span_hook=hook), configure_observability=None) + + async def handler(_request: Any, _context: Any, _cancellation_signal: Any) -> Any: + pytest.fail("Malformed attribution must not reach a handler.") + + app.response_handler(handler) + response = TestClient(app).post( + "/responses", + json={"model": "test", "input": "hi", "store": False}, + headers={"x-ms-original-subject": value}, + ) + assert response.status_code == 400 + assert response.headers["x-platform-error-source"] == "platform" + assert len(hook.spans) == 1 + assert hook.spans[0].error is not None + assert get_request_context().original_subject is None + + +@pytest.mark.asyncio +async def test_model_sdk_extra_headers_preserve_the_dictionary_and_independent_json_header() -> None: + observed: list[httpx.Request] = [] + + def receive(request: httpx.Request) -> httpx.Response: + observed.append(request) + return httpx.Response( + 200, + json={ + "id": "resp_test", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "test", + "output": [], + }, + ) + + subject_value = 'oid="original-oid", tid="original-tid", idtype="user"' + token = set_request_context( + FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject.from_headers({"x-ms-original-subject": subject_value}), + call_id="call-current", + user_id="different-state-key", + ) + ) + independent_json = json.dumps({"application_id": "example"}, separators=(",", ":"), ensure_ascii=True) + try: + async with AsyncOpenAI( + api_key="test-model-key", + base_url="https://model.test", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(receive)), + ) as client: + response = await client.responses.create( + model="test", + input="Hello", + extra_headers=get_request_context().model_attribution_headers( + { + "x-example-context": independent_json, + "X-MS-Foundry-Baggage": "application=example,Foundry.Original_Subject=stale", + "X-MS-Original-Subject": "stale", + } + ), + ) + assert response.id == "resp_test" + finally: + reset_request_context(token) + + assert len(observed) == 1 + assert not any(name.lower().startswith("x-ms-original-subject") for name in observed[0].headers) + baggage = observed[0].headers["x-ms-foundry-baggage"].split(",") + assert baggage[1] == "application=example" + assert unquote(baggage[0].split("=", 1)[1]) == subject_value + assert len(baggage) == 2 + assert observed[0].headers["x-agent-foundry-call-id"] == "call-current" + assert observed[0].headers["x-example-context"] == independent_json + assert observed[0].headers["authorization"] == "Bearer test-model-key" + assert "x-agent-user-id" not in observed[0].headers + assert get_request_context().original_subject is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_reused_model_sdk_isolates_concurrent_invocations_streams_and_retries(stream: bool) -> None: + observed: list[tuple[str, dict[str, str]]] = [] + attempts: dict[str, int] = {} + arrivals: set[str] = set() + concurrent = asyncio.Event() + + async def receive(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + invocation = body["input"] + observed.append((invocation, dict(request.headers))) + attempts[invocation] = attempts.get(invocation, 0) + 1 + if attempts[invocation] == 1: + if invocation != "without-subject": + arrivals.add(invocation) + if len(arrivals) == 3: + concurrent.set() + await asyncio.wait_for(concurrent.wait(), timeout=5) + return httpx.Response( + 503, + headers={"retry-after-ms": "1"}, + json={"error": {"message": "Retry this synthetic request.", "type": "server_error"}}, + ) + response = { + "id": "resp_test", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "test", + "output": [], + } + if stream: + event = {"type": "response.completed", "sequence_number": 0, "response": response} + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=f"event: response.completed\ndata: {json.dumps(event)}\n\n", + ) + return httpx.Response(200, json=response) + + existing = { + "X-MS-Original-Subject": "stale", + "X-MS-Original-Subject-Oid": "stale", + "X-MS-Original-Subject-Tid": "stale", + "X-MS-Original-Subject-Idtype": "stale", + "X-AGENT-FOUNDRY-CALL-ID": "stale-call", + "X-MS-Foundry-Baggage": ( + "foundry.agent.name=example,Foundry.Original_Subject;stale=1,foundry.original_subject.oid=stale" + ), + "x-example-context": json.dumps({"application_id": "example"}), + } + original_headers = existing.copy() + parent = get_request_context() + http_client: Any = httpx.AsyncClient( + transport=httpx.MockTransport(receive), headers={"x-transport-default": "unchanged"} + ) + transport_defaults = dict(http_client.headers) + async with AsyncOpenAI( + api_key="test-model-key", + base_url="https://model.test", + max_retries=1, + organization="test-organization", + project="test-project", + default_headers={"x-shared-client": "unchanged"}, + http_client=http_client, + ) as client: + client_defaults = dict(client.default_headers) + authentication = dict(client.auth_headers) + + async def invoke(invocation: str, subject: FoundryOriginalSubject | None) -> None: + context = FoundryAgentRequestContext( + original_subject=subject, call_id=f"call-{invocation}", user_id="independent-state-key" + ) + token = set_request_context(context) + try: + await asyncio.sleep(0) + result = await client.responses.create( + model="test", + input=invocation, + stream=stream, + extra_headers=get_request_context().model_attribution_headers(existing), + ) + if stream: + assert isinstance(result, AsyncStream) + events = [event async for event in result] + assert len(events) == 1 + event = events[0] + assert event.type == "response.completed" + assert event.response.id == "resp_test" + else: + assert not isinstance(result, AsyncStream) + assert result.id == "resp_test" + assert get_request_context() is context + finally: + reset_request_context(token) + + subjects = { + f"invocation-{index}": FoundryOriginalSubject(oid=f'subject-{index}%2C+;/\\"', tid="tenant", idtype=idtype) + for index, idtype in enumerate(("user", "app", "future")) + } + await asyncio.gather(*(invoke(invocation, subject) for invocation, subject in subjects.items())) + await invoke("without-subject", None) + assert dict(client.default_headers) == client_defaults + assert dict(client.auth_headers) == authentication + assert dict(http_client.headers) == transport_defaults + + assert get_request_context() is parent + assert existing == original_headers + assert attempts == {**dict.fromkeys(subjects, 2), "without-subject": 2} + for invocation, headers in observed: + assert headers["authorization"] == authentication["Authorization"] + assert headers["x-shared-client"] == "unchanged" + assert headers["x-transport-default"] == "unchanged" + assert headers["x-example-context"] == existing["x-example-context"] + assert "x-agent-user-id" not in headers + assert not any(name.lower().startswith("x-ms-original-subject") for name in headers) + if invocation == "without-subject": + assert "x-agent-foundry-call-id" not in headers + assert headers["x-ms-foundry-baggage"] == "foundry.agent.name=example" + else: + assert headers["x-agent-foundry-call-id"] == f"call-{invocation}" + members = headers["x-ms-foundry-baggage"].split(",") + assert members[1] == "foundry.agent.name=example" + assert len(members) == 2 + assert members[0].startswith("foundry.original_subject=") + index = invocation.removeprefix("invocation-") + expected = rf'oid="subject-{index}%2C+;/\\\"", tid="tenant", idtype="{subjects[invocation].idtype}"' + assert unquote(members[0].split("=", 1)[1]) == expected def _build_capturing_client( @@ -43,6 +287,8 @@ async def _capturing_handler(request: Any, context: Any, cancellation_signal: An captured["call_id"] = rc.call_id captured["user_id"] = rc.user_id captured["session_id"] = rc.session_id + captured["model_headers"] = rc.model_attribution_headers() + captured["platform_headers"] = rc.platform_headers() stream = ResponseEventStream(response_id=context.response_id, model=getattr(request, "model", None)) yield stream.emit_created() @@ -59,6 +305,32 @@ async def _capturing_handler(request: Any, context: Any, cancellation_signal: An class TestStreamingRequestContext: """The platform context must be visible inside streaming handler bodies.""" + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("stored", [False, True]) + def test_original_subject_survives_handler_rebinding(self, stream: bool, stored: bool, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + client = _build_capturing_client(captured, resilient=stored, storage_dir=tmp_path / "store" if stored else None) + subject = { + "x-ms-original-subject": 'oid="original-oid", tid="original-tid", idtype="user"', + } + response = client.post( + "/responses", + json={"model": "m", "input": "hi", "stream": stream, "store": stored}, + headers={**subject, "x-agent-foundry-call-id": "call-current", "x-agent-user-id": "state-key"}, + ) + assert response.status_code == 200 + assert captured["model_headers"] == { + "x-agent-foundry-call-id": "call-current", + "x-ms-foundry-baggage": "foundry.original_subject=" + quote(subject["x-ms-original-subject"], safe=""), + } + assert ( + unquote(captured["model_headers"]["x-ms-foundry-baggage"].split("=", 1)[1]) + == subject["x-ms-original-subject"] + ) + assert captured["platform_headers"] == {"x-agent-foundry-call-id": "call-current"} + assert captured["user_id"] == "state-key" + assert get_request_context().original_subject is None + def test_streaming_handler_sees_call_id_and_user_id(self) -> None: captured: dict[str, Any] = {} client = _build_capturing_client(captured) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_tracing.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_tracing.py index 7fca0625c6ad..f189539c6843 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_tracing.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_tracing.py @@ -92,6 +92,7 @@ def test_tracing__span_tags_include_genai_parity_fields() -> None: assert tags["gen_ai.agent.name"] == "my-agent" assert tags["gen_ai.agent.id"] == "my-agent:v2" assert tags["gen_ai.provider.name"] == "AzureAI Hosted Agents" + assert tags["microsoft.foundry"] == "True" assert tags["service.name"] == "azure.ai.agentserver" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py index 7bbdaac1b29f..53e6260a818f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py @@ -7,7 +7,9 @@ the same key. Mismatched or missing keys return an indistinguishable 404 to prevent cross-user information leakage. -Backward-compatible: no enforcement when the response was created without a key. +These host lifecycle tests use an explicitly partitioned provider. Anonymous +responses use a distinct partition and are not visible to keyed requests. +Persistent provider implementations must independently enforce this contract. """ from __future__ import annotations @@ -15,12 +17,14 @@ import asyncio import json as _json from typing import Any +from unittest.mock import AsyncMock import pytest from starlette.testclient import TestClient -from azure.ai.agentserver.responses import ResponsesAgentServerHost +from azure.ai.agentserver.responses import PlatformContext, ResponsesAgentServerHost, ResponsesServerOptions from azure.ai.agentserver.responses._id_generator import IdGenerator +from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider from azure.ai.agentserver.responses.streaming._event_stream import ResponseEventStream from tests._helpers import poll_until @@ -36,7 +40,7 @@ async def _events(): def _make_client(handler=_noop_handler) -> TestClient: - host = ResponsesAgentServerHost() + host = ResponsesAgentServerHost(store=InMemoryResponseProvider()) host.response_handler(handler) return TestClient(host) @@ -204,14 +208,646 @@ async def _events(): def _build_async_client(handler: Any) -> _AsyncAsgiClient: - app = ResponsesAgentServerHost() + app = ResponsesAgentServerHost(store=InMemoryResponseProvider()) app.response_handler(handler) return _AsyncAsgiClient(app) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "owner_key,other_key,evict", + [ + ("key_A", "key_B", False), + ("key_A", None, False), + ("key_A", "key_B", True), + ("key_A", None, True), + (None, "key_A", True), + ], +) +async def test_memory_provider_isolation_before_and_after_runtime_eviction( + owner_key: str | None, other_key: str | None, evict: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """Envelope/input provider fallbacks retain the caller's partition after eviction.""" + provider = InMemoryResponseProvider() + host = ResponsesAgentServerHost(store=provider) + host.response_handler(_noop_handler) + client = _AsyncAsgiClient(host) + runtime = host._orchestrator._runtime_state + try_evict = runtime.try_evict + # Hold eager terminal eviction so both sides of the provider fallback are exercised. + monkeypatch.setattr(runtime, "try_evict", AsyncMock(return_value=False)) + owner_headers = {"x-agent-user-id": owner_key} if owner_key is not None else {} + other_headers = {"x-agent-user-id": other_key} if other_key is not None else {} + created = await client.post( + "/responses", + json_body={"model": "m", "input": [{"role": "user", "content": "private input"}], "store": True}, + headers={**owner_headers, "x-agent-foundry-call-id": "creation-call"}, + ) + assert created.status_code == 200 + response_id = created.json()["id"] + monkeypatch.setattr(runtime, "try_evict", try_evict) + assert await runtime.get(response_id, owner_key) is not None + if evict: + assert await runtime.try_evict(response_id, owner_key) + assert await runtime.get(response_id, owner_key) is None + + path = f"/responses/{response_id}" + for method, endpoint in [("GET", path), ("GET", f"{path}/input_items"), ("DELETE", path)]: + denied = await client.request(method, endpoint, headers=other_headers) + assert denied.status_code == 404, denied.body + with pytest.raises(KeyError): + await provider.update_response(created.json(), context=PlatformContext(user_id_key=other_key)) + + later_headers = {**owner_headers, "x-agent-foundry-call-id": "later-call"} + fetched = await client.get(path, headers=later_headers) + assert fetched.status_code == 200 + inputs = await client.get(f"{path}/input_items", headers=later_headers) + assert inputs.status_code == 200 + assert inputs.json()["data"][0]["content"][0]["text"] == "private input" + deleted = await client.request("DELETE", path, headers=later_headers) + assert deleted.status_code == 200 + with pytest.raises(KeyError): + await provider.get_response(response_id, context=PlatformContext(user_id_key=owner_key)) + + +@pytest.mark.asyncio +async def test_duplicate_live_response_id_is_rejected_without_replacing_owner() -> None: + handler = _make_cancellable_bg_handler() + client = _build_async_client(handler) + response_id = IdGenerator.new_response_id() + owner_headers = {"x-agent-user-id": "key_A"} + + owner_task = asyncio.create_task( + client.post( + "/responses", + json_body={ + "response_id": response_id, + "model": "test", + "background": True, + "stream": True, + }, + headers=owner_headers, + ) + ) + try: + await asyncio.wait_for(handler.started.wait(), timeout=5.0) + collision = await client.post( + "/responses", + json_body={"response_id": response_id, "model": "test", "background": True}, + headers=owner_headers, + ) + assert collision.status_code == 409 + assert collision.json()["error"]["code"] == "response_id_conflict" + assert collision.json()["error"]["message"] == ( + "An active execution or retained replay stream with this response ID already exists." + ) + + owner = await client.get(f"/responses/{response_id}", headers=owner_headers) + assert owner.status_code == 200 + denied = await client.get( + f"/responses/{response_id}", + headers={"x-agent-user-id": "key_B"}, + ) + assert denied.status_code == 404 + finally: + if not owner_task.done(): + owner_task.cancel() + with pytest.raises((asyncio.CancelledError, Exception)): + await owner_task + + +def test_response_id_with_retained_stream_cannot_be_reused_by_same_user() -> None: + response_id = IdGenerator.new_response_id() + + async def completed_streaming_handler(request: Any, context: Any, cancellation_signal: asyncio.Event): + async def _events(): + stream = ResponseEventStream(response_id=context.response_id, model=request.model) + yield stream.emit_created() + yield stream.emit_completed() + + return _events() + + client = _make_client(completed_streaming_handler) + with client.stream( + "POST", + "/responses", + json={ + "response_id": response_id, + "model": "test", + "background": False, + "stream": True, + "store": True, + }, + headers={"x-agent-user-id": "key_A"}, + ) as owner: + assert owner.status_code == 200 + list(owner.iter_lines()) + + collision = client.post( + "/responses", + json={ + "response_id": response_id, + "model": "test", + "background": False, + "stream": False, + "store": True, + }, + headers={"x-agent-user-id": "key_A"}, + ) + + assert collision.status_code == 409 + assert collision.json()["error"]["code"] == "response_id_conflict" + assert collision.json()["error"]["message"] == ( + "An active execution or retained replay stream with this response ID already exists." + ) + + # ── GET with isolation ──────────────────────────────────── +@pytest.mark.asyncio +@pytest.mark.parametrize("first_user", ["key_A", None, ""]) +@pytest.mark.parametrize("cancel_first", [True, False]) +async def test_same_id_streams_cancel_replay_and_delete_independently(first_user, cancel_first) -> None: + users = [first_user, "key_B"] + started = {user: asyncio.Event() for user in users} + released = {user: asyncio.Event() for user in users} + labels = {user: f"private-output-{index}" for index, user in enumerate(users)} + response_id = IdGenerator.new_response_id() + + async def handler(request, context, cancellation_signal): + user = context.platform_context.user_id_key + + async def events(): + stream = ResponseEventStream(response_id=context.response_id, model=labels[user]) + yield stream.emit_created() + yield stream.emit_in_progress() + for event in stream.output_item_message(labels[user]): + yield event + started[user].set() + while not released[user].is_set() and not cancellation_signal.is_set(): + await asyncio.sleep(0.001) + if not cancellation_signal.is_set(): + yield stream.emit_completed() + + return events() + + host = ResponsesAgentServerHost( + store=InMemoryResponseProvider(), options=ResponsesServerOptions(resilient_background=False) + ) + host.response_handler(handler) + client = _AsyncAsgiClient(host) + headers = {user: ({"x-agent-user-id": user} if user is not None else {}) for user in users} + payload = {"response_id": response_id, "model": "m", "background": True, "stream": True, "store": True} + tasks = [asyncio.create_task(client.post("/responses", json_body=payload, headers=headers[user])) for user in users] + try: + await asyncio.wait_for(asyncio.gather(*(event.wait() for event in started.values())), timeout=5) + for user in users: + response = await client.get(f"/responses/{response_id}", headers=headers[user]) + assert response.status_code == 200 + assert response.json()["model"] == labels[user] + + duplicate = await client.post("/responses", json_body=payload, headers=headers[first_user]) + assert duplicate.status_code == 409 + if cancel_first: + cancelled = await client.post(f"/responses/{response_id}/cancel", headers=headers[first_user]) + assert cancelled.status_code == 200 + else: + released[first_user].set() + other = await client.get(f"/responses/{response_id}", headers=headers["key_B"]) + assert other.json()["status"] == "in_progress" + + released["key_B"].set() + responses = await asyncio.wait_for(asyncio.gather(*tasks), timeout=5) + for user, response in zip(users, responses): + assert response.status_code == 200 + assert labels[user].encode() in response.body + assert labels[users[1] if user == users[0] else users[0]].encode() not in response.body + + async def wait_for_eviction(): + while await host._endpoint._runtime_state.list_records(): + await asyncio.sleep(0.001) + + await asyncio.wait_for(wait_for_eviction(), timeout=5) + denied = await client.get( + f"/responses/{response_id}?stream=true", headers={"x-agent-user-id": "unrelated-user"} + ) + assert denied.status_code == 404 + assert all(label.encode() not in denied.body for label in labels.values()) + for user in users: + replay = await client.get(f"/responses/{response_id}?stream=true", headers=headers[user]) + if cancel_first and user == first_user: + # Cancellation deliberately removes only this user's replay. + assert replay.status_code == 400 + assert labels["key_B"].encode() not in replay.body + continue + assert replay.status_code == 200, replay.body + assert labels[user].encode() in replay.body + assert labels[users[1] if user == users[0] else users[0]].encode() not in replay.body + + deleted = await client.request("DELETE", f"/responses/{response_id}", headers=headers[first_user]) + assert deleted.status_code == 200 + assert (await client.get(f"/responses/{response_id}", headers=headers[first_user])).status_code == 404 + replay = await client.get(f"/responses/{response_id}?stream=true", headers=headers["key_B"]) + assert replay.status_code == 200 + assert labels["key_B"].encode() in replay.body + finally: + for event in released.values(): + event.set() + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_persisted_response_remains_accessible_while_same_id_is_live_for_another_user() -> None: + handler = _make_cancellable_bg_handler() + provider = InMemoryResponseProvider() + response_id = IdGenerator.new_response_id() + await provider.create_response( + {"id": response_id, "status": "completed", "model": "owner", "output": []}, + input_items=[], + history_item_ids=[], + context=PlatformContext(user_id_key="key_A"), + ) + host = ResponsesAgentServerHost(store=provider) + host.response_handler(handler) + client = _AsyncAsgiClient(host) + task = asyncio.create_task( + client.post( + "/responses", + json_body={"response_id": response_id, "model": "other", "background": True, "stream": True}, + headers={"x-agent-user-id": "key_B"}, + ) + ) + try: + await asyncio.wait_for(handler.started.wait(), timeout=5) + response = await client.get(f"/responses/{response_id}", headers={"x-agent-user-id": "key_A"}) + assert response.status_code == 200 + assert response.json()["model"] == "owner" + deleted = await client.request("DELETE", f"/responses/{response_id}", headers={"x-agent-user-id": "key_A"}) + assert deleted.status_code == 200 + other = await client.get(f"/responses/{response_id}", headers={"x-agent-user-id": "key_B"}) + assert other.status_code == 200 + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure,status", [("missing", 404), ("denied", 400), ("unavailable", 500)]) +async def test_provider_failure_never_falls_through_to_retained_replay(monkeypatch, failure, status): + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + from azure.ai.agentserver.responses.hosting import _endpoint_handler + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + from azure.ai.agentserver.responses.store._foundry_errors import ( + FoundryApiError, + FoundryBadRequestError, + FoundryResourceNotFoundError, + ) + + response_id = IdGenerator.new_response_id() + registry = _StreamsRegistry() + registry.use_in_memory_replay(cursor_fn=lambda event: event["sequence_number"]) + monkeypatch.setattr(_endpoint_handler, "streams", registry) + stream = await registry.get_or_create(derive_lifecycle_id(response_id, "owner")) + await stream.emit({"sequence_number": 0, "type": "response.output_text.delta", "delta": "private-data"}) + await stream.close() + provider = InMemoryResponseProvider() + errors = { + "missing": FoundryResourceNotFoundError("missing"), + "denied": FoundryBadRequestError("denied"), + "unavailable": FoundryApiError("unavailable"), + } + + async def unavailable(*args, **kwargs): + raise errors[failure] + + monkeypatch.setattr(provider, "get_response", unavailable) + host = ResponsesAgentServerHost(store=provider) + client = _AsyncAsgiClient(host) + response = await client.get(f"/responses/{response_id}?stream=true", headers={"x-agent-user-id": "owner"}) + assert response.status_code == status + assert b"private-data" not in response.body + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_reservation_only_create_cannot_access_another_users_stored_response(streaming): + response_id = IdGenerator.new_response_id() + provider = InMemoryResponseProvider() + await provider.create_response( + {"id": response_id, "status": "completed", "model": "owner-private", "output": []}, + input_items=[], + history_item_ids=[], + context=PlatformContext(user_id_key="owner"), + ) + entered = asyncio.Event() + released = asyncio.Event() + + async def handler(request, context, cancellation_signal): + async def events(): + entered.set() + await released.wait() + stream = ResponseEventStream(response_id=context.response_id, model="ephemeral") + yield stream.emit_created() + yield stream.emit_completed() + + return events() + + host = ResponsesAgentServerHost(store=provider) + host.response_handler(handler) + client = _AsyncAsgiClient(host) + headers = {"x-agent-user-id": "other"} + payload = {"response_id": response_id, "model": "m", "store": False, "stream": streaming} + task = asyncio.create_task(client.post("/responses", json_body=payload, headers=headers)) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + assert await host._endpoint._runtime_state.get(response_id, "other") is None + assert (await client.post("/responses", json_body=payload, headers=headers)).status_code == 409 + for method, suffix in [ + ("GET", ""), + ("GET", "?stream=true"), + ("GET", "/input_items"), + ("DELETE", ""), + ("POST", "/cancel"), + ]: + response = await client.request(method, f"/responses/{response_id}{suffix}", headers=headers) + assert response.status_code == 404, response.body + assert b"owner-private" not in response.body + owner = await client.get(f"/responses/{response_id}", headers={"x-agent-user-id": "owner"}) + assert owner.status_code == 200 + assert owner.json()["model"] == "owner-private" + released.set() + assert (await asyncio.wait_for(task, timeout=5)).status_code == 200 + finally: + released.set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["create", "replay", "delete", "delete_failure", "runtime_delete_failure"]) +async def test_cold_file_replay_is_discovered_without_creating_absent_streams(tmp_path, monkeypatch, operation): + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + from azure.ai.agentserver.responses.hosting import _endpoint_handler + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + + provider = InMemoryResponseProvider() + host = ResponsesAgentServerHost(store=provider) + host.response_handler(_noop_handler) + client = _AsyncAsgiClient(host) + response_id = IdGenerator.new_response_id() + stream_id = derive_lifecycle_id(response_id, "owner") + storage_dir = tmp_path / "replay" + owner_headers = {"x-agent-user-id": "owner"} + await provider.create_response( + {"id": response_id, "status": "completed", "model": "test", "background": True, "output": []}, + input_items=[], + history_item_ids=[], + context=PlatformContext(user_id_key="owner"), + ) + first = _StreamsRegistry() + first.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + original = await first.get_or_create(stream_id) + await original.emit({"type": "response.output_text.delta", "sequence_number": 0, "delta": "owner-private"}) + await original.close() + original._cleanup_locks() + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + monkeypatch.setattr(_endpoint_handler, "streams", restarted) + try: + before = set(storage_dir.iterdir()) + denied = await client.get(f"/responses/{response_id}?stream=true", headers={"x-agent-user-id": "other"}) + assert denied.status_code == 404 + assert set(storage_dir.iterdir()) == before + assert not restarted._slots + + if operation == "create": + collision = await client.post( + "/responses", json_body={"response_id": response_id, "model": "test"}, headers=owner_headers + ) + assert collision.status_code == 409 + assert collision.json()["error"]["code"] == "response_id_conflict" + other = await client.post( + "/responses", + json_body={"response_id": response_id, "model": "test", "store": False}, + headers={"x-agent-user-id": "other"}, + ) + assert other.status_code == 200 + assert set(storage_dir.glob("*.jsonl")) == before + elif operation == "replay": + replay = await client.get(f"/responses/{response_id}?stream=true", headers=owner_headers) + assert replay.status_code == 200, replay.body + assert b"owner-private" in replay.body + else: + if operation in {"delete_failure", "runtime_delete_failure"}: + from pathlib import Path + + if operation == "runtime_delete_failure": + from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags + + await host._endpoint._runtime_state.add( + ResponseExecution( + response_id=response_id, + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + status="completed", + user_id_key="owner", + ) + ) + unlink = Path.unlink + + def denied(path, *args, **kwargs): + if path.suffix == ".jsonl": + raise PermissionError("replay removal denied") + return unlink(path, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + failed = await client.request("DELETE", f"/responses/{response_id}", headers=owner_headers) + assert failed.status_code == 500, failed.body + assert set(storage_dir.glob("*.jsonl")) == before + owner = await provider.get_response(response_id, context=PlatformContext(user_id_key="owner")) + assert owner["id"] == response_id + assert not await host._endpoint._runtime_state.is_deleted(response_id, "owner") + if operation == "runtime_delete_failure": + assert await host._endpoint._runtime_state.get(response_id, "owner") is not None + deleted = await client.request("DELETE", f"/responses/{response_id}", headers=owner_headers) + assert deleted.status_code == 200, deleted.body + assert list(storage_dir.iterdir()) == [] + finally: + await restarted.delete(stream_id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("runtime_record", [False, True]) +@pytest.mark.parametrize("pause_at", ["stream", "provider"]) +@pytest.mark.parametrize("cancel_delete", [False, True]) +async def test_delete_blocks_same_user_recreation_until_cleanup_finishes( + monkeypatch, runtime_record, pause_at, cancel_delete +): + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + from azure.ai.agentserver.responses.hosting import _endpoint_handler + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags + + provider = InMemoryResponseProvider() + host = ResponsesAgentServerHost(store=provider) + host.response_handler(_noop_handler) + client = _AsyncAsgiClient(host) + state = host._endpoint._runtime_state + response_id = IdGenerator.new_response_id() + headers = {"x-agent-user-id": "owner"} + await provider.create_response( + {"id": response_id, "status": "completed", "model": "old", "background": True, "output": []}, + input_items=[], + history_item_ids=[], + context=PlatformContext(user_id_key="owner"), + ) + if runtime_record: + await state.add( + ResponseExecution( + response_id=response_id, + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + status="completed", + user_id_key="owner", + ) + ) + registry = _StreamsRegistry() + registry.use_in_memory_replay() + monkeypatch.setattr(_endpoint_handler, "streams", registry) + old_stream = await registry.get_or_create(derive_lifecycle_id(response_id, "owner")) + await old_stream.emit({"old": True}) + await old_stream.close() + reached = asyncio.Event() + release = asyncio.Event() + stream_delete = registry.delete + provider_delete = provider.delete_response + + async def paused_stream_delete(identifier): + await stream_delete(identifier) + if runtime_record: + await state.try_evict(response_id, "owner") + if pause_at == "stream": + reached.set() + await release.wait() + + async def paused_provider_delete(*args, **kwargs): + if pause_at == "provider": + reached.set() + await release.wait() + return await provider_delete(*args, **kwargs) + + monkeypatch.setattr(registry, "delete", paused_stream_delete) + monkeypatch.setattr(provider, "delete_response", paused_provider_delete) + deleting = asyncio.create_task(client.request("DELETE", f"/responses/{response_id}", headers=headers)) + try: + await asyncio.wait_for(reached.wait(), timeout=5) + retained = await state.get(response_id, "owner") + assert retained is not None + assert not await state.try_evict(response_id, "owner") + collision = await client.post( + "/responses", json_body={"response_id": response_id, "model": "new", "store": False}, headers=headers + ) + assert collision.status_code == 409, collision.body + competing_delete = await client.request("DELETE", f"/responses/{response_id}", headers=headers) + assert competing_delete.status_code == 404 + other = await client.post( + "/responses", + json_body={"response_id": response_id, "model": "other", "store": False}, + headers={"x-agent-user-id": "other"}, + ) + assert other.status_code == 200, other.body + if cancel_delete: + deleting.cancel() + with pytest.raises(asyncio.CancelledError): + await deleting + assert await state.get(response_id, "owner") is retained + assert not await state.is_deleted(response_id, "owner") + assert not await state.reserve(response_id, "owner") + release.set() + retry = await client.request("DELETE", f"/responses/{response_id}", headers=headers) + assert retry.status_code == 200, retry.body + else: + release.set() + result = await asyncio.wait_for(deleting, timeout=5) + assert result.status_code == 200, result.body + assert await state.reserve(response_id, "owner") + await state.release_reservation(response_id, "owner") + finally: + release.set() + if not deleting.done(): + deleting.cancel() + await asyncio.gather(deleting, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_recovered_streaming_tasks_use_their_durable_user_partition(monkeypatch): + from types import SimpleNamespace + from azure.ai.agentserver.responses.hosting import _resilient_orchestrator + from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput + from azure.ai.agentserver.responses.models import CreateResponse + + monkeypatch.setattr(_resilient_orchestrator, "_RUNTIME_REFS", {}) + provider = InMemoryResponseProvider() + response_id = IdGenerator.new_response_id() + seen = [] + + async def handler(request, context, cancellation_signal): + async def events(): + user = context.platform_context.user_id_key + assert context.is_recovery + assert context.persisted_response["model"] == user + seen.append(user) + stream = ResponseEventStream(response_id=context.response_id, model=user) + yield stream.emit_created() + for event in stream.output_item_message(f"recovered-private-{user}"): + yield event + yield stream.emit_completed() + + return events() + + host = ResponsesAgentServerHost(store=provider) + host.response_handler(handler) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + contexts = [] + for user in ["user-A", "user-B"]: + await provider.create_response( + {"id": response_id, "status": "in_progress", "model": user, "background": True, "output": []}, + input_items=[], + history_item_ids=[], + context=PlatformContext(user_id_key=user), + ) + durable = ResilientResponseInput( + request=CreateResponse(model=user, stream=True, store=True, background=True, input="hello"), + response_id=response_id, + disposition="re-invoke", + user_id_key=user, + ) + contexts.append( + SimpleNamespace( + input=durable.to_task_input(), + task_id=f"recovered-task-{user}", + entry_mode="recovered", + is_steered_turn=False, + pending_input_count=0, + cancel=asyncio.Event(), + shutdown=asyncio.Event(), + ) + ) + + await asyncio.wait_for(asyncio.gather(*(orchestrator._execute_in_task(context) for context in contexts)), timeout=5) + assert sorted(seen) == ["user-A", "user-B"] + client = _AsyncAsgiClient(host) + for user, other in [("user-A", "user-B"), ("user-B", "user-A")]: + replay = await client.get(f"/responses/{response_id}?stream=true", headers={"x-agent-user-id": user}) + assert replay.status_code == 200, replay.body + assert f"recovered-private-{user}".encode() in replay.body + assert f"recovered-private-{other}".encode() not in replay.body + + class TestGetUserIsolation: """GET /responses/{id} with user ID enforcement. @@ -297,14 +933,14 @@ async def test_get_missing_key_when_created_with_key_returns_404(self) -> None: except (asyncio.CancelledError, Exception): pass - def test_get_created_without_key_any_request_returns_200(self) -> None: - """GET with or without key when response was created without one → 200 (backward compat).""" + def test_get_created_without_key_is_visible_only_to_anonymous_requests(self) -> None: + """Completed anonymous responses do not become visible to identified users.""" client = _make_client() resp = _create_response(client) _wait_for_terminal(client, resp["id"]) # With a key r = client.get(f"/responses/{resp['id']}", headers={"x-agent-user-id": "any_key"}) - assert r.status_code == 200 + assert r.status_code == 404 # Without a key r = client.get(f"/responses/{resp['id']}") assert r.status_code == 200 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/_crash_harness.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/_crash_harness.py index f28383d5f269..18a540f965b9 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/_crash_harness.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/_crash_harness.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +# cspell:ignore EADDRINUSE """Crash-injection harness for cross-process recovery testing (T-051). Spawns an HTTP server as a subprocess, exposes ``kill()`` (SIGKILL) and @@ -60,7 +61,8 @@ class CrashHarness: :type tmp_path: ~pathlib.Path :param port: Optional explicit port. If ``None``, the harness binds an ephemeral port (bind 0, read assignment) and passes it to the - subprocess via ``PORT`` env var. + subprocess via ``PORT`` env var. On Linux the auto-picked port stays + reserved until :meth:`close` (see ``_reserve_ephemeral_port``). :type port: int | None :param readiness_timeout_seconds: How long to wait for the subprocess to respond to the ``/health/live`` probe. Default 10. @@ -100,7 +102,11 @@ def __init__( (self._tmp_path / "responses").mkdir(parents=True, exist_ok=True) (self._tmp_path / "streams").mkdir(parents=True, exist_ok=True) - self._port = port if port is not None else self._pick_ephemeral_port() + self._port_reservation: socket.socket | None = None + if port is None: + self._port, self._port_reservation = self._reserve_ephemeral_port() + else: + self._port = port self._readiness_timeout = readiness_timeout_seconds self._env_extras = dict(env_extras or {}) @@ -115,16 +121,37 @@ def __init__( self._subprocess_log_paths: list[Path] = [] @staticmethod - def _pick_ephemeral_port() -> int: - """Pick an ephemeral port by binding to 0 and reading the assignment. - - :returns: A port number believed to be free at this moment. (TOCTOU - races are possible but unlikely on a single dev box.) - :rtype: int + def _reserve_ephemeral_port() -> tuple[int, socket.socket | None]: + """Pick an ephemeral port and, on Linux, keep it reserved until ``close()``. + + A bind-0-then-close pick leaves a window before the subprocess binds in + which another process (e.g. a concurrently running wheel/sdist test leg) + can pick the same port. The losing subprocess then fails with + ``EADDRINUSE`` while the readiness probe reaches the other process's + server. On Linux a bound, non-listening ``SO_REUSEADDR`` socket excludes + the port from other ``bind(0)`` picks and outgoing ephemeral ports, while + the subprocess (which also binds with ``SO_REUSEADDR``) can still bind and + listen on it, including across :meth:`restart`. Other platforms differ in + how a co-bound non-listening socket affects incoming connections, so the + port is released immediately there. + + :returns: The port and the reservation socket (``None`` when not reserved). + :rtype: tuple[int, ~socket.socket | None] """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + reserve = sys.platform.startswith("linux") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + if reserve: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) + port = int(sock.getsockname()[1]) + except BaseException: + sock.close() + raise + if not reserve: + sock.close() + return port, None + return port, sock @property def port(self) -> int: @@ -405,6 +432,9 @@ async def close(self) -> None: if self._process is not None and self._process.poll() is None: await self.kill() self._process = None + if self._port_reservation is not None: + self._port_reservation.close() + self._port_reservation = None # Close subprocess log file handles. Path list is retained so # tests/helpers can inspect logs after close (debug aid). for fh in self._subprocess_log_handles: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_steering_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_steering_handler.py index d5f68eda85e3..b8ae32eb4051 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_steering_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_steering_handler.py @@ -41,7 +41,6 @@ import asyncio import os -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled from azure.ai.agentserver.responses import ( CreateResponse, ResponseContext, @@ -78,11 +77,6 @@ def _env_int(name: str, default: int) -> int: steerable_conversations=True, shutdown_grace_period_seconds=_SHUTDOWN_GRACE_S, ) -# Steering (mid-turn input queuing) is implemented by the multi-turn task -# primitive and needs the TaskManager. Since only ``resilient_background`` now -# auto-enables the subsystem (steerable does not), enable it explicitly so the -# steering conformance is valid even when CONFORMANCE_RESILIENT_BACKGROUND=false. -set_resilient_tasks_enabled(True) app = ResponsesAgentServerHost(options=options) _turn_counts: dict[str, int] = {} diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_test_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_test_handler.py index e7d796589285..eeebbdcdda22 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_test_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/_test_handler.py @@ -29,10 +29,8 @@ (Spec 024 Phase 3a unified storage layout.) - ``CONFORMANCE_RESILIENT_BACKGROUND`` — ``"true"`` or ``"false"`` to select the server's ``resilient_background`` option. Default ``"true"``. -- ``CONFORMANCE_RESILIENT_BACKGROUND`` — ``"true"`` to set - ``ResponsesServerOptions(resilient_background=True)``. - (forces row 4 ephemeral regardless of per-request ``store`` flag). - Default ``"false"``. +- ``CONFORMANCE_STEERABLE_CONVERSATIONS`` — ``"true"`` enables steering. + Default ``"false"``. Either Responses feature initializes TaskManager. - ``CONFORMANCE_HANDLER_SLEEP_MS`` — milliseconds the handler sleeps between the pre-sleep delta burst and the post-sleep delta burst. Default ``50`` (fast natural completion). @@ -53,7 +51,6 @@ import asyncio import os -from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled from azure.ai.agentserver.responses import ( CreateResponse, ResponseContext, @@ -100,14 +97,9 @@ def _env_int(name: str, default: int) -> int: options = ResponsesServerOptions( resilient_background=_RESILIENT_BG, + steerable_conversations=_env_bool("CONFORMANCE_STEERABLE_CONVERSATIONS", False), shutdown_grace_period_seconds=_SHUTDOWN_GRACE_S, ) -# Conformance exercises the durable-response subsystem (Rows 1/2/3), which is -# gated on the resilient-tasks switch. Row 2 runs with resilient_background=False -# (which does NOT auto-enable the switch), so enable it explicitly here — the -# server must construct the TaskManager and run recovery to honour the Row 2 -# mark-failed-on-crash contract. -set_resilient_tasks_enabled(True) app = ResponsesAgentServerHost(options=options) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/conftest.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/conftest.py index d02e7d69c432..3d6ada8bdd3c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/conftest.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/conftest.py @@ -98,7 +98,7 @@ def make_harness(tmp_path: Path) -> Callable[..., CrashHarness]: Returns a callable that takes: - ``resilient_background`` (bool, default True) — server option. - - ```` (bool, default False) — server option. + - ``steerable_conversations`` (bool, default False) — server option. - ``handler_sleep_ms`` (int, default 50) — handler sleep before emitting completion. - ``shutdown_grace_seconds`` (int, default LONG_GRACE_S) — server's @@ -114,6 +114,7 @@ def make_harness(tmp_path: Path) -> Callable[..., CrashHarness]: def _factory( *, resilient_background: bool = True, + steerable_conversations: bool = False, handler_sleep_ms: int = 50, pre_sleep_deltas: int = 0, explicit_exit_for_recovery: bool = False, @@ -123,6 +124,7 @@ def _factory( ) -> CrashHarness: env = { "CONFORMANCE_RESILIENT_BACKGROUND": "true" if resilient_background else "false", + "CONFORMANCE_STEERABLE_CONVERSATIONS": "true" if steerable_conversations else "false", "CONFORMANCE_HANDLER_SLEEP_MS": str(handler_sleep_ms), "CONFORMANCE_PRE_SLEEP_DELTAS": str(pre_sleep_deltas), "CONFORMANCE_EXPLICIT_EXIT_FOR_RECOVERY": ("true" if explicit_exit_for_recovery else "false"), diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_a.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_a.py index fb0dc409d080..65d257c8192e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_a.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_a.py @@ -31,6 +31,7 @@ async def test_row_2_path_a(make_harness: Callable[..., CrashHarness], stream: b """Row 2 Path A: non-resilient+bg handler completes naturally within grace.""" harness = make_harness( resilient_background=False, + steerable_conversations=True, handler_sleep_ms=50, shutdown_grace_seconds=LONG_GRACE_S, ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_b.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_b.py index 4a598059b316..5f825944ab82 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_b.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_b.py @@ -36,6 +36,7 @@ async def test_row_2_path_b(make_harness: Callable[..., CrashHarness], stream: b """Row 2 Path B: graceful shutdown, grace exhausted, in-process marker fires.""" harness = make_harness( resilient_background=False, + steerable_conversations=True, handler_sleep_ms=int(LONG_TIME_SECS * 1000), shutdown_grace_seconds=SHORT_GRACE_S, ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_c.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_c.py index 4bc157d92c14..04d163610fb1 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_c.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_2_path_c.py @@ -8,14 +8,8 @@ next-lifetime recovery. The reconnecting client sees the failed terminal — NOT ``in_progress`` indefinitely. -EXPECTED today: **RED — divergence 2.** ``_orchestrator.py:2273`` gates -``_start_resilient_background`` on ``resilient_background AND store``. With -``resilient_background=False`` no resilient record is created; next-lifetime -recovery finds nothing for the response; nothing marks it failed. -The response stays ``in_progress`` indefinitely. - -Phase 4 closes this by creating a bookkeeping resilient record for every -``store=true`` response (per RD-1) with disposition ``mark-failed``. +With steering enabled this flag-off response has a durable task runtime. +With both Responses features disabled, no recovery scanner runs instead. Contract source: ``resilience-contract.md`` § Per-row contracts → Row 2. """ @@ -39,10 +33,12 @@ @pytest.mark.asyncio @pytest.mark.parametrize("stream", [False, True], ids=["stream=False", "stream=True"]) -async def test_row_2_path_c(make_harness: Callable[..., CrashHarness], stream: bool) -> None: - """Row 2 Path C: SIGKILL mid-handler, restart, response marked failed.""" +@pytest.mark.parametrize("steerable", [False, True], ids=["runtime-off", "steering-runtime"]) +async def test_row_2_path_c(make_harness: Callable[..., CrashHarness], stream: bool, steerable: bool) -> None: + """Only a feature-enabled task runtime marks an interrupted response failed.""" harness = make_harness( resilient_background=False, + steerable_conversations=steerable, handler_sleep_ms=int(LONG_TIME_SECS * 1000), pre_sleep_deltas=1 if stream else 0, shutdown_grace_seconds=LONG_GRACE_S, @@ -62,9 +58,15 @@ async def test_row_2_path_c(make_harness: Callable[..., CrashHarness], stream: b await harness.kill() await harness.restart() - terminal = await poll_until_terminal(harness.client, response_id) - assert terminal["status"] == "failed", terminal - error = terminal.get("error") or {} - assert error.get("code") in ("server_error", "server_crashed"), error + if steerable: + terminal = await poll_until_terminal(harness.client, response_id) + assert terminal["status"] == "failed", terminal + error = terminal.get("error") or {} + assert error.get("code") in ("server_error", "server_crashed"), error + else: + await asyncio.sleep(LONG_TIME_SECS + 1) + response = await harness.client.get(f"/responses/{response_id}") + assert response.status_code == 200, response.text + assert response.json()["status"] == "in_progress", response.text finally: await harness.close() diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_4_path_c.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_4_path_c.py index bd3c4ad8661a..f76301c47e5c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_4_path_c.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/resilience_contract/test_row_4_path_c.py @@ -77,9 +77,9 @@ async def _fire() -> None: await harness.restart() # (a) No leftover state in the response store. - resp_dir = tmp_path / "responses" / "responses" + resp_dir = tmp_path / "responses" / "partitions-v1" if resp_dir.exists(): - files = list(resp_dir.glob("*.json")) + files = list(resp_dir.rglob("*.json")) assert not files, ( f"Row 4 Path C: store=false should leave no response files, " f"found: {[f.name for f in files]}" ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_crash_harness_self.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_crash_harness_self.py index ac3fb375fd9e..3480d1fdb10e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_crash_harness_self.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_crash_harness_self.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +# cspell:ignore EADDRINUSE """Self-tests for the crash-injection harness (T-052). Exercises the harness against a trivial built-in HTTP server (not against any @@ -13,7 +14,9 @@ from __future__ import annotations +import errno import platform +import socket import textwrap from pathlib import Path @@ -26,18 +29,23 @@ \"\"\"Minimal echo HTTP server used by crash-harness self-tests.\"\"\" import os import sys - from http.server import BaseHTTPRequestHandler, HTTPServer + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class _EchoHandler(BaseHTTPRequestHandler): + # Keep-alive: clients close first, so the server port never enters TIME_WAIT. + protocol_version = "HTTP/1.1" + def do_GET(self): if self.path == "/health/live": self.send_response(200) self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", "2") self.end_headers() self.wfile.write(b"OK") return self.send_response(404) + self.send_header("Content-Length", "0") self.end_headers() def log_message(self, format, *args): @@ -46,7 +54,7 @@ def log_message(self, format, *args): def main(): port = int(os.environ.get("PORT", "0") or "0") - server = HTTPServer(("127.0.0.1", port), _EchoHandler) + server = ThreadingHTTPServer(("127.0.0.1", port), _EchoHandler) server.serve_forever() @@ -88,9 +96,12 @@ async def test_harness_kill_terminates_subprocess(tmp_path: Path, echo_server_pa harness = CrashHarness(sample_module=echo_server_path, tmp_path=tmp_path) await harness.start() pid = harness.pid - assert pid is not None - await harness.kill() - assert harness.pid is None + try: + assert pid is not None + await harness.kill() + assert harness.pid is None + finally: + await harness.close() @pytest.mark.asyncio @@ -139,3 +150,36 @@ async def test_harness_close_is_idempotent(tmp_path: Path, echo_server_path: Pat await harness.start() await harness.close() await harness.close() # second close is a no-op + + +@pytest.mark.asyncio +async def test_harness_reserves_auto_picked_port_across_restart(tmp_path: Path, echo_server_path: Path) -> None: + """Other pickers cannot take the auto-picked port before spawn or between lifetimes. + + CI runs wheel and sdist legs concurrently; an unreserved port let a second + harness lose the bind race and probe the other leg's server as its own. + """ + harness = CrashHarness(sample_module=echo_server_path, tmp_path=tmp_path) + + def _bind_exclusive() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", harness.port)) + + try: + with pytest.raises(OSError) as before_spawn: + _bind_exclusive() + assert before_spawn.value.errno == errno.EADDRINUSE + await harness.start() + await harness.kill() + with pytest.raises(OSError) as between_lifetimes: + _bind_exclusive() + assert between_lifetimes.value.errno == errno.EADDRINUSE + await harness.restart() + response = await harness.client.get("/health/live") + assert response.status_code == 200 + await harness.kill() + with pytest.raises(OSError) as after_restart: + _bind_exclusive() + assert after_restart.value.errno == errno.EADDRINUSE + finally: + await harness.close() diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_file_response_store.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_file_response_store.py index 31f6fb73ef0c..7f131fec0a75 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_file_response_store.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_file_response_store.py @@ -17,6 +17,7 @@ import pytest from azure.ai.agentserver.responses import FileResponseStore +from azure.ai.agentserver.responses._response_context import PlatformContext from azure.ai.agentserver.responses.models._generated import ResponseObject from azure.ai.agentserver.responses.store import ResponseAlreadyExistsError @@ -33,13 +34,17 @@ def _make_response(response_id: str = "resp_test", status: str = "in_progress") return ResponseObject(data) +def _anonymous_root(storage_dir: Path) -> Path: + return storage_dir / "partitions-v1" / "anonymous" + + @pytest.mark.asyncio async def test_create_response_persists_to_file(tmp_path: Path) -> None: """``create_response`` writes a JSON file at the documented layout.""" store = FileResponseStore(storage_dir=tmp_path) response = _make_response("resp_001") await store.create_response(response, input_items=None, history_item_ids=None) - assert (tmp_path / "responses" / "resp_001.json").exists() + assert (_anonymous_root(tmp_path) / "responses" / "resp_001.json").exists() @pytest.mark.asyncio @@ -53,6 +58,20 @@ async def test_get_response_round_trips(tmp_path: Path) -> None: assert str(fetched["status"]) == "in_progress" +@pytest.mark.asyncio +async def test_missing_read_does_not_create_partition_directories(tmp_path: Path) -> None: + """Read-only misses do not create empty user partition directories.""" + store = FileResponseStore(storage_dir=tmp_path) + + with pytest.raises(KeyError): + await store.get_response( + "resp_missing", + context=PlatformContext(user_id_key="unknown-user"), + ) + + assert list((tmp_path / "partitions-v1").iterdir()) == [] + + @pytest.mark.asyncio async def test_create_response_raises_on_duplicate(tmp_path: Path) -> None: """A second create for the same response_id raises ResponseAlreadyExistsError.""" @@ -129,5 +148,55 @@ async def test_atomic_write_no_partial_file_on_concurrent_read(tmp_path: Path) - response = _make_response("resp_atomic") await store.create_response(response, input_items=None, history_item_ids=None) # Tempfile should not survive a completed write. - tmp_files = list((tmp_path / "responses").glob("*.tmp")) + tmp_files = list((_anonymous_root(tmp_path) / "responses").glob("*.tmp")) assert tmp_files == [] + + +@pytest.mark.asyncio +async def test_same_response_id_is_isolated_by_user_and_survives_restart(tmp_path: Path) -> None: + """Named users can persist the same IDs without seeing or mutating each other's state.""" + user_a = PlatformContext(user_id_key="user-A", call_id="call-A") + user_b = PlatformContext(user_id_key="user-B", call_id="call-B") + store = FileResponseStore(storage_dir=tmp_path) + + await store.create_response( + _make_response("resp_shared", status="in_progress"), + input_items=None, + history_item_ids=["item_a"], + context=user_a, + ) + await store.create_response( + _make_response("resp_shared", status="completed"), + input_items=None, + history_item_ids=["item_b"], + context=user_b, + ) + + restarted = FileResponseStore(storage_dir=tmp_path) + assert str((await restarted.get_response("resp_shared", context=user_a))["status"]) == "in_progress" + assert str((await restarted.get_response("resp_shared", context=user_b))["status"]) == "completed" + assert await restarted.get_history_item_ids("resp_shared", None, 10, context=user_a) == ["item_a"] + assert await restarted.get_history_item_ids("resp_shared", None, 10, context=user_b) == ["item_b"] + + await restarted.delete_response("resp_shared", context=user_a) + with pytest.raises(KeyError): + await restarted.get_response("resp_shared", context=user_a) + assert str((await restarted.get_response("resp_shared", context=user_b))["status"]) == "completed" + + +@pytest.mark.asyncio +async def test_anonymous_partition_is_separate_and_legacy_files_are_not_read(tmp_path: Path) -> None: + """Anonymous state is isolated, and pre-partition files receive no unsafe ownership fallback.""" + legacy_responses = tmp_path / "responses" + legacy_responses.mkdir() + (legacy_responses / "resp_legacy.json").write_text( + '{"id":"resp_legacy","object":"response","status":"completed","output":[]}' + ) + + store = FileResponseStore(storage_dir=tmp_path) + with pytest.raises(KeyError): + await store.get_response("resp_legacy") + + await store.create_response(_make_response("resp_shared"), None, None) + with pytest.raises(KeyError): + await store.get_response("resp_shared", context=PlatformContext(user_id_key="user-A")) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_stream_recovery_e2e.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_stream_recovery_e2e.py index bade59b6d073..4d2a0aaf14a3 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_stream_recovery_e2e.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/e2e/test_stream_recovery_e2e.py @@ -194,6 +194,7 @@ class TestFileBackedStreamsRegistry: async def test_stores_and_replays(self, tmp_path: Path) -> None: saved_slots = dict(streams._slots) # type: ignore[attr-defined] saved_factory = streams._factory # type: ignore[attr-defined] + saved_restore = streams._restore streams._slots.clear() # type: ignore[attr-defined] try: streams.use_file_backed_replay( @@ -224,11 +225,13 @@ async def test_stores_and_replays(self, tmp_path: Path) -> None: streams._slots.clear() # type: ignore[attr-defined] streams._slots.update(saved_slots) # type: ignore[attr-defined] streams._factory = saved_factory # type: ignore[attr-defined] + streams._restore = saved_restore @pytest.mark.asyncio async def test_ttl_evicts_closed_buffer(self, tmp_path: Path) -> None: saved_slots = dict(streams._slots) # type: ignore[attr-defined] saved_factory = streams._factory # type: ignore[attr-defined] + saved_restore = streams._restore streams._slots.clear() # type: ignore[attr-defined] try: streams.use_file_backed_replay( @@ -253,3 +256,4 @@ async def test_ttl_evicts_closed_buffer(self, tmp_path: Path) -> None: streams._slots.clear() # type: ignore[attr-defined] streams._slots.update(saved_slots) # type: ignore[attr-defined] streams._factory = saved_factory # type: ignore[attr-defined] + streams._restore = saved_restore diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_protocol_task_enablement.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_protocol_task_enablement.py new file mode 100644 index 000000000000..a9330fdb65e7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_protocol_task_enablement.py @@ -0,0 +1,300 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Responses feature activation is independent of the Invocations switch.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +import httpx +from starlette.testclient import TestClient + +from azure.ai.agentserver.core.tasks import ( + TaskManagerNotInitialized, + resilient_tasks_enabled, + set_resilient_tasks_enabled, +) +from azure.ai.agentserver.core.tasks._manager import get_task_manager +from azure.ai.agentserver.responses import ResponseEventStream, ResponsesAgentServerHost, ResponsesServerOptions + + +@pytest.mark.parametrize("switch", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("store,background", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("stream", [False, True]) +def test_responses_activation_and_request_mode_matrix( + monkeypatch: pytest.MonkeyPatch, + switch: bool, + resilient: bool, + steerable: bool, + store: bool, + background: bool, + stream: bool, +) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local") + set_resilient_tasks_enabled(switch) + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + assert resilient_tasks_enabled() is switch + observed: list[tuple[bool, bool]] = [] + + @host.response_handler + async def handler(request, context, cancellation_signal): + try: + get_task_manager() + manager_available = True + except TaskManagerNotInitialized: + manager_available = False + observed.append((manager_available, context._task_context is not None)) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + with TestClient(host) as client: + assert (host._task_manager is not None) is (resilient or steerable) + response = client.post( + "/responses", + json={"model": "m", "input": "hello", "store": store, "background": background, "stream": stream}, + ) + if background and not store: + assert response.status_code == 400 + assert response.json()["error"]["param"] == "background" + assert observed == [] + else: + assert response.status_code == 200, response.text + assert observed == [((resilient or steerable), store and (resilient or steerable))] + assert resilient_tasks_enabled() is switch + + +@pytest.mark.parametrize("resilient,steerable", [(False, True), (True, False), (True, True)]) +@pytest.mark.asyncio +async def test_requested_runtime_startup_failure_is_not_downgraded( + monkeypatch: pytest.MonkeyPatch, resilient: bool, steerable: bool +) -> None: + from azure.ai.agentserver.core.tasks._manager import TaskManager + + set_resilient_tasks_enabled(False) + shutdown = AsyncMock() + monkeypatch.setattr(TaskManager, "startup", AsyncMock(side_effect=RuntimeError("task store unavailable"))) + monkeypatch.setattr(TaskManager, "shutdown", shutdown) + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + with pytest.raises(RuntimeError, match="task store unavailable"): + async with host.router.lifespan_context(host): + pytest.fail("Startup must fail") + assert host._task_manager is None + shutdown.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_overlapping_hosts_do_not_share_task_manager(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider + from azure.ai.agentserver.core.tasks._manager import TaskManager + + providers = iter([LocalFileTaskProvider(tmp_path / "first"), LocalFileTaskProvider(tmp_path / "second")]) + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(lambda _: next(providers))) + set_resilient_tasks_enabled(False) + first = ResponsesAgentServerHost( + options=ResponsesServerOptions(steerable_conversations=True), configure_observability=None + ) + second = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True), configure_observability=None + ) + async with first.router.lifespan_context(first): + first_manager = get_task_manager() + async with second.router.lifespan_context(second): + second_manager = get_task_manager() + assert first_manager is not second_manager + assert get_task_manager() is first_manager + assert first._task_manager is first_manager + assert first._task_manager is None + assert second._task_manager is None + + +def test_disabled_responses_does_not_use_another_hosts_manager() -> None: + set_resilient_tasks_enabled(True) + enabled = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True), configure_observability=None + ) + disabled = ResponsesAgentServerHost(configure_observability=None) + observed: list[bool] = [] + + @disabled.response_handler + async def handler(request, context, cancellation_signal): + with pytest.raises(TaskManagerNotInitialized): + get_task_manager() + observed.append(context._task_context is None) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + with TestClient(enabled), TestClient(disabled) as client: + result = client.post("/responses", json={"input": "hello", "store": True}) + assert result.status_code == 200, result.text + assert observed == [True] + + +@pytest.mark.parametrize("multi_turn", [False, True]) +@pytest.mark.parametrize("other_enabled", [False, True]) +@pytest.mark.parametrize("construct_after_startup", [False, True]) +@pytest.mark.parametrize("snapshot_mode", [False, True]) +@pytest.mark.asyncio +async def test_recovery_uses_owning_hosts_handler_provider_and_options( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + multi_turn: bool, + other_enabled: bool, + construct_after_startup: bool, + snapshot_mode: bool, +) -> None: + from azure.ai.agentserver.core.tasks._lease import derive_lease_owner + from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider + from azure.ai.agentserver.core.tasks._manager import TaskManager, _SCHEMA_VERSION, _SCHEMA_VERSION_KEY + from azure.ai.agentserver.core.tasks._models import TaskCreateRequest + from azure.ai.agentserver.responses import PlatformContext + from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + from azure.ai.agentserver.responses.models._generated import CreateResponse, ResponseObject + from azure.ai.agentserver.responses.store._file import FileResponseStore + + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", "true" if snapshot_mode else "false") + if snapshot_mode: + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "snapshot-owner-session") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + task_store = LocalFileTaskProvider(tmp_path / "tasks") + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(lambda _: task_store)) + set_resilient_tasks_enabled(False) + stores = [FileResponseStore(storage_dir=tmp_path / name) for name in ("first", "second")] + first = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True, steerable_conversations=True), + store=stores[0], + configure_observability=None, + ) + observed = [] + + @first.response_handler + async def first_handler(request, context, cancellation_signal): + observed.append(("first", context.is_recovery, get_task_manager() is first._task_manager)) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + for event in events.output_item_message("recovered by first"): + yield event + yield events.emit_completed() + + def construct_other_host() -> ResponsesAgentServerHost: + second = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=other_enabled), + store=stores[1], + configure_observability=None, + ) + + @second.response_handler + async def second_handler(request, context, cancellation_signal): + observed.append(("second", context.is_recovery, get_task_manager() is first._task_manager)) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + for event in events.output_item_message("recovered by second"): + yield event + yield events.emit_completed() + + return second + + response_id = "resp_host_callback_owner" + platform_context = PlatformContext(user_id_key="owner") + for store in stores: + await store.create_response( + ResponseObject( + { + "id": response_id, + "object": "response", + "status": "in_progress", + "background": True, + "model": "m", + "output": [], + } + ), + [], + None, + context=platform_context, + ) + orchestrator = first._endpoint._orchestrator._resilient_orchestrator + primitive = orchestrator._multi_turn_task_fn if multi_turn else orchestrator._one_shot_task_fn + + async def persist_interrupted_task() -> None: + request = CreateResponse(model="m", input="recover this", store=True, background=True, stream=False) + if multi_turn: + request["conversation"] = "conv_callback_owner" + params = ResilientResponseInput( + request=request, response_id=response_id, disposition="re-invoke", user_id_key="owner" + ).to_task_input() + payload = {"input": params, _SCHEMA_VERSION_KEY: _SCHEMA_VERSION} + if multi_turn: + payload["last_input_id"] = derive_lifecycle_id(response_id, "owner") + agent = first.config.agent_name or "default" + session = first.config.session_id or "local" + info = await task_store.create( + TaskCreateRequest( + id=derive_lifecycle_id("task-owner", "owner"), + agent_name=agent, + session_id=session, + status="in_progress", + title="Interrupted response for callback ownership", + payload=payload, + tags={"task_name": primitive.name}, + source=TaskManager._build_source(primitive.name), + lease_owner=derive_lease_owner(first.config.agent_name or "unknown-agent", session), + lease_instance_id="interrupted-instance", + lease_duration_seconds=60, + ) + ) + assert info.lease is not None + info.lease.expires_at = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() + task_store._write_task(info) + + async def wait_for_terminal(): + while True: + snapshots = [await store.get_response(response_id, context=platform_context) for store in stores] + if any(snapshot["status"] in ("completed", "failed", "cancelled") for snapshot in snapshots): + return snapshots + await asyncio.sleep(0.01) + + if not construct_after_startup: + construct_other_host() + await persist_interrupted_task() + async with first.router.lifespan_context(first): + if snapshot_mode: + assert first._task_manager is None + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=first), base_url="http://test") as client: + restored = await client.post( + "/_agent/after-restore", + json={ + "session_context": { + "session_id": "snapshot-owner-session", + "restore_id": "snapshot-owner-restore", + "session_env_overrides": {"FOUNDRY_AGENT_SESSION_GUID": "a" * 32}, + } + }, + ) + assert restored.status_code == 200, restored.text + if construct_after_startup: + construct_other_host() + await persist_interrupted_task() + await first._task_manager._recover_stale_tasks() + recovered, untouched = await asyncio.wait_for(wait_for_terminal(), timeout=10) + assert observed == [("first", True, True)] + assert recovered["status"] == "completed" + assert recovered["output"][0]["content"][0]["text"] == "recovered by first" + assert untouched["status"] == "in_progress" + assert untouched["output"] == [] + assert first._task_manager._resume_callbacks[primitive.name] is primitive._fn + assert first._task_manager._resume_opts[primitive.name] is primitive._opts diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_snapshot_protocol_task_enablement.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_snapshot_protocol_task_enablement.py new file mode 100644 index 000000000000..19985422223a --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_snapshot_protocol_task_enablement.py @@ -0,0 +1,288 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Snapshot activation preserves independent protocol task-runtime requirements.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import httpx +import pytest +from starlette.responses import JSONResponse +from starlette.testclient import TestClient + +from azure.ai.agentserver.core.tasks import ( + TaskContext, + TaskManagerNotInitialized, + resilient_tasks_enabled, + set_resilient_tasks_enabled, + task, +) +from azure.ai.agentserver.core.tasks import _decorator +from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider +from azure.ai.agentserver.core.tasks._manager import TaskManager, get_task_manager +from azure.ai.agentserver.responses import ResponseEventStream, ResponsesAgentServerHost, ResponsesServerOptions + + +@pytest.fixture(autouse=True) +def snapshot_runtime(monkeypatch, tmp_path): + definitions = list(_decorator._REGISTERED_DESCRIPTORS) + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "snapshot-protocol") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_ID", "snapshot-session") + monkeypatch.setenv("FOUNDRY_AGENT_SESSION_GUID", "a" * 32) + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local") + monkeypatch.setenv("AGENTSERVER_SHUTDOWN_GRACE_SECONDS", "0") + provider = LocalFileTaskProvider(tmp_path / "tasks") + factory = Mock(return_value=provider) + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(factory)) + try: + yield factory + finally: + _decorator._REGISTERED_DESCRIPTORS[:] = definitions + + +def restore_payload(): + return { + "session_context": { + "session_id": "snapshot-session", + "restore_id": "snapshot-restore", + "session_env_overrides": {"FOUNDRY_AGENT_SESSION_GUID": "a" * 32}, + } + } + + +def manager_available(): + try: + get_task_manager() + return True + except TaskManagerNotInitialized: + return False + + +def snapshot_mode(monkeypatch, value): + if value is None: + monkeypatch.delenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", raising=False) + else: + monkeypatch.setenv("FOUNDRY_MEMORY_SNAPSHOT_ENABLED", value) + + +def combined_type(invocations_type, responses_first): + if responses_first: + + class CombinedHost(ResponsesAgentServerHost, invocations_type): + pass + + else: + + class CombinedHost(invocations_type, ResponsesAgentServerHost): + pass + + return CombinedHost + + +def register_response_probe(host, observed): + @host.response_handler + async def response_handler(request, context, cancellation_signal): + observed.append(manager_available()) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_completed() + + +@pytest.mark.parametrize("value", [None, "false", "true"]) +@pytest.mark.parametrize("switch", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +def test_responses_snapshot_uses_options_not_invocations_switch( + monkeypatch, snapshot_runtime, value, switch, resilient, steerable +): + snapshot_mode(monkeypatch, value) + set_resilient_tasks_enabled(switch) + enabled = resilient or steerable + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + observed, executed = [], [] + register_response_probe(host, observed) + callbacks = 0 + + @task(name="snapshot_callback_probe") + async def probe(ctx: TaskContext[str]) -> str: + executed.append(ctx.input) + return ctx.input + + @host.after_restore_handler + async def restored(context): + nonlocal callbacks + callbacks += 1 + assert manager_available() is enabled + if enabled: + manager = get_task_manager() + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + for primitive in (orchestrator._one_shot_task_fn, orchestrator._multi_turn_task_fn): + assert manager._resume_callbacks[primitive.name] is primitive._fn + assert manager._resume_opts[primitive.name] is primitive._opts + if value == "true": + assert not manager._startup_phases["recovery"].completed + assert manager._periodic_recovery_task is None + assert await probe.run(input=context.session_id) == "snapshot-session" + else: + with pytest.raises(TaskManagerNotInitialized): + await probe.start(input=context.session_id) + + assert resilient_tasks_enabled() is switch + with TestClient(host) as client: + assert (host._task_manager is not None) is (enabled and value != "true") + if value == "true": + snapshot_runtime.assert_not_called() + assert client.post("/_agent/before-snapshot", json={}).status_code == 200 + snapshot_runtime.assert_not_called() + first = client.post("/_agent/after-restore", json=restore_payload()) + assert first.status_code == 200, first.text + manager = host._task_manager + assert client.post("/_agent/after-restore", json=restore_payload()).status_code == 200 + assert host._task_manager is manager + assert callbacks == 1 + assert executed == (["snapshot-session"] if enabled else []) + assert client.post("/responses", json={"input": "probe", "store": False}).status_code == 200 + assert observed == [enabled] + if enabled: + assert manager._startup_phases["ready"].completed + assert manager._periodic_recovery_task is not None + assert not manager._periodic_recovery_task.done() + snapshot_runtime.assert_called_once() + else: + snapshot_runtime.assert_not_called() + assert host._task_manager is None + assert resilient_tasks_enabled() is switch + + +@pytest.mark.parametrize("value", [None, "false", "true"]) +@pytest.mark.parametrize("enabled", [False, True]) +def test_invocations_snapshot_switch_controls_http_and_websocket(monkeypatch, value, enabled): + invocations = pytest.importorskip("azure.ai.agentserver.invocations") + snapshot_mode(monkeypatch, value) + set_resilient_tasks_enabled(enabled) + host = invocations.InvocationAgentServerHost(configure_observability=None) + + @host.invoke_handler + async def invoked(request): + return JSONResponse({"enabled": manager_available()}) + + @host.ws_handler + async def websocket(websocket): + await websocket.send_json({"enabled": manager_available()}) + + with TestClient(host) as client: + assert (host._task_manager is not None) is (enabled and value != "true") + assert client.post("/_agent/after-restore", json=restore_payload()).status_code == 200 + assert client.post("/invocations", json={}).json() == {"enabled": enabled} + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_json() == {"enabled": enabled} + + +@pytest.mark.parametrize("switch", [False, True]) +@pytest.mark.parametrize("resilient,steerable", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("responses_first", [False, True]) +def test_composed_snapshot_preserves_protocol_access( + monkeypatch, snapshot_runtime, switch, resilient, steerable, responses_first +): + invocations = pytest.importorskip("azure.ai.agentserver.invocations") + snapshot_mode(monkeypatch, "true") + set_resilient_tasks_enabled(switch) + host = combined_type(invocations.InvocationAgentServerHost, responses_first)( + options=ResponsesServerOptions(resilient_background=resilient, steerable_conversations=steerable), + configure_observability=None, + ) + observed = [] + register_response_probe(host, observed) + + @host.invoke_handler + async def invoked(request): + return JSONResponse({"enabled": manager_available()}) + + @host.ws_handler + async def websocket(websocket): + await websocket.send_json({"enabled": manager_available()}) + + with TestClient(host) as client: + snapshot_runtime.assert_not_called() + assert host._task_manager is None + assert client.post("/_agent/after-restore", json=restore_payload()).status_code == 200 + assert (host._task_manager is not None) is (switch or resilient or steerable) + assert client.post("/invocations", json={}).json() == {"enabled": switch} + with client.websocket_connect("/invocations_ws") as websocket: + assert websocket.receive_json() == {"enabled": switch} + assert client.post("/responses", json={"input": "probe", "store": False}).status_code == 200 + assert observed == [resilient or steerable] + assert client.post("/_agent/after-restore", json=restore_payload()).status_code == 200 + assert snapshot_runtime.call_count == int(switch or resilient or steerable) + assert resilient_tasks_enabled() is switch + + +@pytest.mark.parametrize("switch,responses_enabled", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("responses_first", [False, True]) +@pytest.mark.parametrize("path", ["/invocations_ws", "/voice_conversation_engine"]) +def test_voice_snapshot_does_not_inherit_responses_opt_in( + monkeypatch, switch, responses_enabled, responses_first, path +): + voice = pytest.importorskip("azure.ai.agentserver.invocations.voice") + snapshot_mode(monkeypatch, "true") + set_resilient_tasks_enabled(switch) + host = combined_type(voice.VoiceAgentServerHost, responses_first)( + options=ResponsesServerOptions(resilient_background=responses_enabled), configure_observability=None + ) + observed = [] + + @host.on_session_start + async def start(session, event): + observed.append(manager_available()) + await session.send(voice.SessionReady()) + + with TestClient(host) as client: + assert host._task_manager is None + assert client.post("/_agent/after-restore", json=restore_payload()).status_code == 200 + with client.websocket_connect(path) as websocket: + websocket.send_json( + { + "type": "session.start", + "id": "snapshot-start", + "ts": "2026-10-26T00:00:00Z", + "protocol_version": "1.0", + "reconnect": False, + "response_timeouts": {"first_output_ms": 1000, "idle_ms": 1000, "max_duration_ms": 3000}, + } + ) + assert websocket.receive_json()["type"] == "session.ready" + assert observed == [switch] + + +@pytest.mark.asyncio +async def test_overlapping_snapshot_hosts_keep_own_runtime(monkeypatch, tmp_path): + snapshot_mode(monkeypatch, "true") + providers = iter([LocalFileTaskProvider(tmp_path / "first"), LocalFileTaskProvider(tmp_path / "second")]) + monkeypatch.setattr(TaskManager, "_create_provider", staticmethod(lambda _: next(providers))) + set_resilient_tasks_enabled(False) + first = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True), configure_observability=None + ) + second = ResponsesAgentServerHost( + options=ResponsesServerOptions(steerable_conversations=True), configure_observability=None + ) + async with first.router.lifespan_context(first): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=first), base_url="http://test") as client: + assert (await client.post("/_agent/after-restore", json=restore_payload())).status_code == 200 + first_manager = get_task_manager() + async with second.router.lifespan_context(second): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=second), base_url="http://test" + ) as other: + assert (await other.post("/_agent/after-restore", json=restore_payload())).status_code == 200 + second_manager = get_task_manager() + assert second_manager is not first_manager + assert (await client.post("/_agent/after-restore", json=restore_payload())).status_code == 200 + assert get_task_manager() is second_manager + assert get_task_manager() is first_manager + assert first._task_manager is first_manager + assert first._task_manager is second._task_manager is None diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_steering_input_handoff.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_steering_input_handoff.py new file mode 100644 index 000000000000..f7753e93d05c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/integration/test_steering_input_handoff.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Queued steering inputs must pass durable admission and execute.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from typing import Any + +import pytest +from starlette.testclient import TestClient + +from azure.ai.agentserver.core.tasks import set_resilient_tasks_enabled +from azure.ai.agentserver.responses import ( + CreateResponse, + ResponseContext, + ResponseEventStream, + ResponsesAgentServerHost, + ResponsesServerOptions, +) + + +@pytest.mark.parametrize("resilient_background", [False, True], ids=["resilience-off", "resilience-on"]) +@pytest.mark.parametrize("chain", ["conversation", "previous-response"]) +@pytest.mark.parametrize("first_terminal", ["return", "completed"]) +def test_queued_turn_executes_after_steering_cancellation( + monkeypatch: pytest.MonkeyPatch, resilient_background: bool, chain: str, first_terminal: str +) -> None: + monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) + monkeypatch.setenv("AGENTSERVER_TASKS_BACKEND", "local") + set_resilient_tasks_enabled(False) + host = ResponsesAgentServerHost( + options=ResponsesServerOptions( + resilient_background=resilient_background, + steerable_conversations=True, + shutdown_grace_period_seconds=1, + ) + ) + cancelled = threading.Event() + entries: list[tuple[str, bool]] = [] + active = 0 + max_active = 0 + + @host.response_handler + async def handler(request: CreateResponse, context: ResponseContext, cancellation_signal: asyncio.Event): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + entries.append((context.response_id, context.is_steered_turn)) + stream = ResponseEventStream(response_id=context.response_id, request=request) + try: + yield stream.emit_created() + yield stream.emit_in_progress() + if len(entries) == 1: + await asyncio.wait_for(cancellation_signal.wait(), timeout=10) + cancelled.set() + if first_terminal == "return": + return + yield stream.emit_completed() + finally: + active -= 1 + + payload: dict[str, Any] = { + "model": "test-model", + "agent_session_id": "steering_handoff_session", + "input": "first", + "store": True, + "background": True, + "stream": False, + } + if chain == "conversation": + payload["conversation"] = "conv_steering_handoff" + + with TestClient(host) as client: + first = client.post("/responses", json=payload) + assert first.status_code == 200, first.text + first_id = first.json()["id"] + second = client.post( + "/responses", + json={**payload, "input": "second", "previous_response_id": first_id}, + ) + assert second.status_code == 200, second.text + assert second.json()["status"] == "queued", second.text + second_id = second.json()["id"] + + deadline = time.monotonic() + 5 + observed: dict[str, Any] = {} + while time.monotonic() < deadline: + response = client.get(f"/responses/{second_id}") + assert response.status_code == 200, response.text + observed = response.json() + if observed["status"] in {"completed", "failed", "cancelled", "incomplete"}: + break + time.sleep(0.01) + + assert cancelled.is_set() + assert observed["status"] == "completed", observed + assert entries == [(first_id, False), (second_id, True)] + assert max_active == 1 + + third = client.post( + "/responses", + json={**payload, "input": "third", "previous_response_id": second_id}, + ) + assert third.status_code == 200, third.text + third_id = third.json()["id"] + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + response = client.get(f"/responses/{third_id}") + assert response.status_code == 200, response.text + observed = response.json() + if observed["status"] in {"completed", "failed", "cancelled", "incomplete"}: + break + time.sleep(0.01) + assert observed["status"] == "completed", observed + assert entries == [(first_id, False), (second_id, True), (third_id, False)] diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py index 4c389306ba13..3e2d8f780d6e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/test_experimental.py @@ -3,8 +3,16 @@ import logging +import pytest + +from azure.ai.agentserver.core import experimental +from azure.ai.agentserver.core._experimental import ( + DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, + EXPERIMENTAL_CLASS_MESSAGE, + _warning_cache, +) from azure.ai.agentserver.responses import FoundryStorageSettings, ResponsesAgentServerHost -from azure.ai.agentserver.responses._experimental import EXPERIMENTAL_CLASS_MESSAGE, _warning_cache, experimental +from azure.ai.agentserver.responses.store import _foundry_errors, _foundry_provider, _foundry_settings def test_foundry_storage_settings_is_experimental(caplog) -> None: @@ -17,6 +25,34 @@ def test_foundry_storage_settings_is_experimental(caplog) -> None: assert EXPERIMENTAL_CLASS_MESSAGE in FoundryStorageSettings.__doc__ assert len(caplog.records) == 1 assert EXPERIMENTAL_CLASS_MESSAGE in caplog.records[0].message + assert caplog.records[0].message.endswith("This is an experimental class, and may change at any time.") + assert "https://" not in FoundryStorageSettings.__doc__ + + +@pytest.mark.parametrize("module", [_foundry_errors, _foundry_provider, _foundry_settings]) +def test_storage_modules_use_core_experimental_decorator(module) -> None: + assert module.experimental is experimental + + +def test_storage_experimental_warnings_are_deduplicated(caplog) -> None: + _warning_cache.clear() + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + FoundryStorageSettings.from_endpoint("https://example.foundry.azure.com") + FoundryStorageSettings.from_endpoint("https://example.foundry.azure.com") + + assert len(caplog.records) == 1 + assert caplog.records[0].name == "azure.ai.agentserver.core._experimental" + + +def test_storage_experimental_warnings_can_be_suppressed(monkeypatch, caplog) -> None: + _warning_cache.clear() + monkeypatch.setenv(DISABLE_EXPERIMENTAL_WARNING_ENV_VAR, "true") + + with caplog.at_level(logging.WARNING, logger=experimental.__module__): + FoundryStorageSettings.from_endpoint("https://example.foundry.azure.com") + + assert len(caplog.records) == 0 def test_protocol_hosts_are_not_marked_experimental() -> None: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_conversation_lock.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_conversation_lock.py index 87b06c744776..127d49404469 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_conversation_lock.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_conversation_lock.py @@ -42,6 +42,7 @@ def _resilient_input_from(ctx_params): agent_reference=ctx_params.get("agent_reference"), agent_session_id=ctx_params.get("session_id"), agent_session_guid=ctx_params.get("session_guid"), + user_id_key=ctx_params.get("user_id_key"), ) @@ -92,7 +93,6 @@ async def test_task_conflict_propagates_from_start_resilient(self) -> None: ) assert excinfo.value.current_status == "in_progress" - @pytest.mark.asyncio async def test_conflict_error_contains_current_status(self) -> None: """Under the spec-022 narrow surface, ``TaskConflictError`` carries @@ -299,6 +299,48 @@ async def test_completed_legacy_task_is_not_reused(self) -> None: assert orchestrator._multi_turn_task_fn.start.await_args.kwargs["task_id"] == expected_guid_id +@pytest.mark.asyncio +@pytest.mark.parametrize("multi_turn", [True, False]) +async def test_task_ids_inputs_and_runtime_refs_are_partitioned(monkeypatch, multi_turn): + from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as module + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id + + monkeypatch.setattr(module, "_RUNTIME_REFS", {}) + orchestrator = TestSessionGuidTaskMigration._orchestrator() + primitive = orchestrator._multi_turn_task_fn + orchestrator._one_shot_task_fn = primitive if not multi_turn else MagicMock() + # Distinct primitive identities are needed for one-shot dispatch. + if not multi_turn: + orchestrator._multi_turn_task_fn = MagicMock() + orchestrator._options.steerable_conversations = False + + task_ids = [] + for user in ["user-A", "user-B"]: + params = { + "response_id": "resp-shared", + "session_id": "session", + "session_guid": "1" * 32, + "user_id_key": user, + } + if multi_turn: + params.update(conversation_id="conv-shared", previous_response_id="resp-previous") + refs = _empty_refs() + resilient_input = _resilient_input_from(params) + await orchestrator.start_resilient(record=MagicMock(), resilient_input=resilient_input, refs=refs) + kwargs = primitive.start.await_args.kwargs + task_ids.append(kwargs["task_id"]) + assert kwargs["input"]["response_id"] == "resp-shared" + assert module._RUNTIME_REFS[derive_lifecycle_id("resp-shared", user)] is refs + if multi_turn: + assert kwargs["input_id"] == derive_lifecycle_id("resp-shared", user) + assert kwargs["if_last_input_id"] == derive_lifecycle_id("resp-previous", user) + else: + assert kwargs["task_id"] == derive_lifecycle_id("resp-shared", user) + assert task_ids[0] != task_ids[1] + primitive._get.assert_not_awaited() + assert len(module._RUNTIME_REFS) == 2 + + class TestNonBackgroundRecovery: """Non-background recovery: task recovered but background=False → fail, don't re-invoke.""" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_store_item_normalization.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_store_item_normalization.py index 7e07d4231346..21cb1af5c4d7 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_store_item_normalization.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_store_item_normalization.py @@ -73,18 +73,19 @@ async def test_envelope_stores_pointers_and_single_item_copy(tmp_path: Path) -> await provider.create_response(_response("r1", output=items), None, None) # Envelope output entries are pointer stubs — NOT full content. - envelope = json.loads((root / "responses" / "r1.json").read_text()) + partition = root / "partitions-v1" / "anonymous" + envelope = json.loads((partition / "responses" / "r1.json").read_text()) out = envelope["output"] assert out == [{_ITEM_REF_KEY: "o1"}, {_ITEM_REF_KEY: "o2"}], out # The single copy of each item lives under items/. for iid, text in (("o1", "alpha"), ("o2", "beta")): - disk = json.loads((root / "items" / f"{iid}.json").read_text()) + disk = json.loads((partition / "items" / f"{iid}.json").read_text()) assert disk["id"] == iid assert disk["content"][0]["text"] == text # The write-only per-response items dir is gone. - assert not (root / "responses" / "r1.items").exists() + assert not (partition / "responses" / "r1.items").exists() # --------------------------------------------------------------------------- @@ -131,7 +132,7 @@ async def test_mixed_idd_and_idless_output_positions(tmp_path: Path) -> None: assert fil_out == mem_out == mixed # On disk: A and C are stubs, B is inline. - envelope = json.loads((tmp_path / "store" / "responses" / "r1.json").read_text()) + envelope = json.loads((tmp_path / "store" / "partitions-v1" / "anonymous" / "responses" / "r1.json").read_text()) assert envelope["output"][0] == {_ITEM_REF_KEY: "oA"} assert envelope["output"][1]["type"] == "reasoning" assert envelope["output"][2] == {_ITEM_REF_KEY: "oC"} @@ -152,7 +153,7 @@ async def test_update_response_rehydrates(tmp_path: Path) -> None: assert [it["id"] for it in out] == ["o1", "o2"] assert out[1]["content"][0]["text"] == "second" - envelope = json.loads((tmp_path / "store" / "responses" / "r1.json").read_text()) + envelope = json.loads((tmp_path / "store" / "partitions-v1" / "anonymous" / "responses" / "r1.json").read_text()) assert envelope["output"] == [{_ITEM_REF_KEY: "o1"}, {_ITEM_REF_KEY: "o2"}] @@ -169,7 +170,7 @@ async def test_missing_item_raises_non_notfound(tmp_path: Path) -> None: fil = FileResponseStore(storage_dir=root) await fil.create_response(_response("r1", output=[_output_item("o1", "x")]), None, None) # Corrupt the store: delete the item the envelope points at. - (root / "items" / "o1.json").unlink() + (root / "partitions-v1" / "anonymous" / "items" / "o1.json").unlink() with pytest.raises(Exception) as ei: # noqa: PT011 await fil.get_response("r1") @@ -195,7 +196,7 @@ async def test_legacy_inline_envelope_still_reads(tmp_path: Path) -> None: "status": "completed", "output": [_output_item("o1", "x")], } - (root / "responses" / "r1.json").write_text(json.dumps(legacy, indent=2)) + (root / "partitions-v1" / "anonymous" / "responses" / "r1.json").write_text(json.dumps(legacy, indent=2)) out = _norm_output(await fil.get_response("r1")) assert out == [_output_item("o1", "x")] @@ -231,9 +232,10 @@ async def test_no_history_json_history_in_indexes(tmp_path: Path) -> None: ["hist_a", "hist_b"], ) # The redundant per-response history file is NOT written. - assert not (root / "responses" / "r1.history.json").exists() + partition = root / "partitions-v1" / "anonymous" + assert not (partition / "responses" / "r1.history.json").exists() # history_item_ids are persisted in indexes.json (the single source). - indexes = json.loads((root / "responses" / "r1.indexes.json").read_text()) + indexes = json.loads((partition / "responses" / "r1.indexes.json").read_text()) assert indexes["history_item_ids"] == ["hist_a", "hist_b"] # And history walking still resolves them. resolved = await fil.get_history_item_ids("r1", None, 100) @@ -243,9 +245,10 @@ async def test_no_history_json_history_in_indexes(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_legacy_history_json_cleaned_on_create(tmp_path: Path) -> None: root = tmp_path / "store" - (root / "responses").mkdir(parents=True) + responses = root / "partitions-v1" / "anonymous" / "responses" + responses.mkdir(parents=True) # Simulate a pre-normalization stray history file. - stray = root / "responses" / "r1.history.json" + stray = responses / "r1.history.json" stray.write_text(json.dumps({"history_item_ids": ["stale"]})) fil = FileResponseStore(storage_dir=root) await fil.create_response(_response("r1", output=[_output_item("o1")]), None, ["fresh"]) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_stream_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_stream_provider.py index 06d6b9074b01..ee257fbd7e7e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_stream_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_stream_provider.py @@ -35,6 +35,7 @@ def _isolate_streams_registry() -> Iterator[None]: saved_slots = dict(streams._slots) # type: ignore[attr-defined] saved_locks = dict(streams._id_locks) # type: ignore[attr-defined] saved_factory = streams._factory # type: ignore[attr-defined] + saved_restore = streams._restore streams._slots.clear() # type: ignore[attr-defined] streams._id_locks.clear() # type: ignore[attr-defined] streams.use_in_memory_live() @@ -46,6 +47,7 @@ def _isolate_streams_registry() -> Iterator[None]: streams._id_locks.clear() # type: ignore[attr-defined] streams._id_locks.update(saved_locks) # type: ignore[attr-defined] streams._factory = saved_factory # type: ignore[attr-defined] + streams._restore = saved_restore def _make_event(seq: int, event_type: str = "response.output_text.delta") -> dict[str, Any]: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py index 9b36e9b0f77a..fd259be0a194 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py @@ -15,8 +15,10 @@ import pytest +from azure.ai.agentserver.responses._response_context import PlatformContext, ResponseContext from azure.ai.agentserver.responses.models import ResponseObject -from azure.ai.agentserver.responses.models.runtime import StreamEventRecord +from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags, StreamEventRecord +from azure.ai.agentserver.responses.store import ResponseAlreadyExistsError from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider # --------------------------------------------------------------------------- @@ -62,6 +64,248 @@ def _output_message(item_id: str, text: str) -> dict[str, Any]: } +_USER_KEYS = ["user_A", "user_B", None, "", " ", " user_A "] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("owner_key,other_key", [(a, b) for a in _USER_KEYS for b in _USER_KEYS if a != b]) +async def test_partitions__foreign_response_and_items_are_missing(owner_key: str | None, other_key: str | None) -> None: + provider = InMemoryResponseProvider() + owner = PlatformContext(user_id_key=owner_key, call_id="shared-call") + other = PlatformContext(user_id_key=other_key, call_id="shared-call") + response = _response("owned", output=[_output_message("output", "private")], conversation_id="conversation") + await provider.create_response(response, [_input_item("input", "private")], None, context=owner) + + for operation in ( + provider.get_response("owned", context=other), + provider.update_response(_response("owned", status="failed"), context=other), + provider.delete_response("owned", context=other), + provider.get_input_items("owned", after="input", before="output", context=other), + ): + with pytest.raises(KeyError, match="not found"): + await operation + assert await provider.get_items(["input", "output"], context=other) == [None, None] + assert await provider.get_history_item_ids("owned", None, 100, context=other) == [] + assert await provider.get_history_item_ids(None, "conversation", 100, context=other) == [] + assert await provider.get_response("owned", context=owner) == response + + +@pytest.mark.asyncio +async def test_partitions__colliding_response_item_and_conversation_ids() -> None: + provider = InMemoryResponseProvider() + contexts = [PlatformContext(user_id_key=key, call_id="create") for key in _USER_KEYS] + for index, context in enumerate(contexts): + await provider.create_response( + _response("same", output=[_output_message("output", str(index))], conversation_id="same"), + [_input_item("input", str(index))], + None, + context=context, + ) + with pytest.raises(ResponseAlreadyExistsError): + await provider.create_response(_response("same"), None, None, context=context) + await provider.create_response( + _response(f"next_{index}", conversation_id="same"), + [_input_item(f"next_input_{index}", str(index))], + await provider.get_history_item_ids("same", None, 100, context=context), + context=context, + ) + + for index, context in enumerate(contexts): + later = PlatformContext(user_id_key=context.user_id_key, call_id="different-call") + items = await provider.get_items(["input", "output", "missing"], context=later) + assert items == [_input_item("input", str(index)), _output_message("output", str(index)), None] + history = await provider.get_history_item_ids(None, "same", 100, context=later) + assert history == ["input", "output", f"next_input_{index}"] + assert await provider.get_history_item_ids(f"next_{index}", None, 100, context=later) == [ + "input", + "output", + f"next_input_{index}", + ] + assert await provider.get_input_items( + f"next_{index}", ascending=True, limit=1, after="input", context=later + ) == [_output_message("output", str(index))] + assert await provider.get_input_items(f"next_{index}", before="input", context=later) == [ + _input_item(f"next_input_{index}", str(index)), + _output_message("output", str(index)), + ] + # An ID from another partition must behave like any other unknown cursor. + foreign_cursor = f"next_input_{(index + 1) % len(contexts)}" + assert await provider.get_input_items("same", after=foreign_cursor, context=later) == [ + _input_item("input", str(index)) + ] + items[0]["content"][0]["text"] = "mutated" + assert (await provider.get_items(["input"], context=later))[0] == _input_item("input", str(index)) + await provider.update_response( + _response("same", output=[_output_message("output", f"updated_{index}")]), context=later + ) + + for index, context in enumerate(contexts): + assert (await provider.get_items(["output"], context=context))[0] == _output_message( + "output", f"updated_{index}" + ) + + await provider.delete_response("same", context=contexts[0]) + with pytest.raises(ValueError, match="deleted"): + await provider.get_input_items("same", context=contexts[0]) + for context in contexts[1:]: + assert (await provider.get_response("same", context=context))["id"] == "same" + assert await provider.get_history_item_ids("same", None, 100, context=context) == ["input", "output"] + + +@pytest.mark.asyncio +async def test_partitions__missing_context_and_unkeyed_context_share_anonymous_crud() -> None: + provider = InMemoryResponseProvider() + unkeyed = PlatformContext(call_id="opaque") + await provider.create_response(_response("anonymous"), [_input_item("input", "local")], None) + assert (await provider.get_response("anonymous", context=unkeyed))["id"] == "anonymous" + await provider.update_response(_response("anonymous", status="failed"), context=unkeyed) + assert (await provider.get_response("anonymous"))["status"] == "failed" + assert await provider.get_items(["input"], context=unkeyed) == [_input_item("input", "local")] + assert await provider.get_input_items("anonymous", context=unkeyed) == [_input_item("input", "local")] + assert await provider.get_history_item_ids("anonymous", None, 100, context=unkeyed) == ["input"] + await provider.delete_response("anonymous", context=unkeyed) + with pytest.raises(KeyError): + await provider.get_response("anonymous") + await provider.create_response(_response("anonymous"), None, None, context=unkeyed) + assert (await provider.get_response("anonymous"))["id"] == "anonymous" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reader_key", ["user_A", "user_B", None]) +@pytest.mark.parametrize("prefetched", [False, True]) +async def test_partitions__response_context_resolves_only_owned_references( + reader_key: str | None, prefetched: bool +) -> None: + provider = InMemoryResponseProvider() + owner = PlatformContext(user_id_key="user_A") + item = _input_item("owned_item", "private") + await provider.create_response(_response("owned", conversation_id="conversation"), [item], None, context=owner) + reader = PlatformContext(user_id_key=reader_key, call_id="next-request") + ctx = ResponseContext( + response_id="next", + mode_flags=ResponseModeFlags(stream=False, store=True, background=False), + provider=provider, + input_items=[{"type": "item_reference", "id": "owned_item"}], + previous_response_id="owned", + conversation_id="conversation", + platform_context=reader, + prefetched_history_ids=["owned_item"] if prefetched else None, + ) + inputs = await ctx.get_input_items() + history = await ctx.get_history() + if reader_key == "user_A": + assert len(inputs) == 1 + assert inputs[0]["content"] == item["content"] + assert history and all(entry == item for entry in history) + else: + assert inputs == () + assert history == () + # Foreign history pointers supplied by a caller never resolve foreign payloads. + await provider.create_response(_response("next"), None, ["owned_item"], context=reader) + assert await provider.get_input_items("next", context=reader) == ([item] if reader_key == "user_A" else []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("other_key", ["user_B", None]) +async def test_partitions__legacy_helpers_do_not_bypass_identity(other_key: str | None) -> None: + provider = InMemoryResponseProvider() + owner = PlatformContext(user_id_key="user_A") + other = PlatformContext(user_id_key=other_key) + execution = ResponseExecution( + response_id="execution", + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + ) + await provider.create_execution(execution, context=owner) + event = StreamEventRecord(sequence_number=0, event_type="response.created", payload={"owner": "A"}) + assert await provider.get_execution("execution", context=other) is None + assert await provider.get_replay_events("execution", context=other) is None + assert not await provider.set_response_snapshot("execution", _response("execution"), context=other) + assert not await provider.transition_execution_status("execution", "in_progress", context=other) + assert not await provider.set_cancel_requested("execution", context=other) + assert not await provider.append_stream_event("execution", event, context=other) + assert not await provider.delete("execution", context=other) + assert await provider.append_stream_event("execution", event, context=owner) + assert (await provider.get_replay_events("execution", context=owner))[0].payload == {"owner": "A"} + assert (await provider.get_execution("execution", context=owner)).status == execution.status + + +@pytest.mark.asyncio +async def test_partitions__legacy_execution_replay_expiry_and_cleanup_collisions() -> None: + provider = InMemoryResponseProvider() + contexts = [PlatformContext(user_id_key="user_A"), PlatformContext(user_id_key="user_B"), None] + execution = ResponseExecution( + response_id="same", + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + ) + for index, context in enumerate(contexts): + await provider.create_execution(execution, context=context) + with pytest.raises(ValueError, match="already exists"): + await provider.create_execution(execution, context=context) + assert await provider.set_response_snapshot("same", _response("same", status="in_progress"), context=context) + assert await provider.transition_execution_status("same", "in_progress", context=context) + old_event = StreamEventRecord( + sequence_number=0, + event_type="response.created", + payload={"owner": index}, + emitted_at=datetime.now(timezone.utc) - timedelta(seconds=601), + ) + live_event = StreamEventRecord( + sequence_number=1, + event_type="response.in_progress", + payload={"owner": index}, + ) + assert await provider.append_stream_event("same", old_event, context=context) + assert await provider.append_stream_event("same", live_event, context=context) + assert (await provider.get_execution("same", context=context)).status == "in_progress" + replay = await provider.get_replay_events("same", context=context) + assert len(replay) == 1 + assert replay[0].payload == {"owner": index} + replay[0].payload["owner"] = "mutated" + assert (await provider.get_replay_events("same", context=context))[0].payload == {"owner": index} + + # Legacy event bookkeeping shares the same composite keys as response entries. + provider._stream_events[("user_A", "same")] = [] + provider._stream_events[("user_B", "same")] = [] + provider._stream_events[(None, "same")] = [] + provider._stream_events[("user_A", "orphan")] = [] + assert await provider.set_cancel_requested("same", ttl_seconds=10, context=contexts[0]) + assert (await provider.get_execution("same", context=contexts[0])).cancel_requested + assert not (await provider.get_execution("same", context=contexts[1])).cancel_requested + assert await provider.purge_expired(now=datetime.now(timezone.utc) + timedelta(seconds=11)) == 1 + assert await provider.get_execution("same", context=contexts[0]) is None + assert ("user_A", "same") not in provider._stream_events + assert ("user_A", "orphan") not in provider._stream_events + for context in contexts[1:]: + assert (await provider.get_execution("same", context=context)).status == "in_progress" + assert len(await provider.get_replay_events("same", context=context)) == 1 + assert await provider.delete("same", context=contexts[1]) + assert ("user_B", "same") not in provider._stream_events + assert (None, "same") in provider._stream_events + assert await provider.get_execution("same") is not None + # Exercise automatic purge on normal lookups, not only the explicit maintenance method. + provider._entries[(None, "same")].expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + assert await provider.get_execution("same") is None + assert provider._stream_events == {} + + +@pytest.mark.asyncio +async def test_partitions__expired_response_leaves_other_users_history_intact() -> None: + provider = InMemoryResponseProvider() + contexts = [PlatformContext(user_id_key="user_A"), PlatformContext(user_id_key="user_B"), None] + for context in contexts: + await provider.create_response( + _response("same", conversation_id="conversation"), [_input_item("input", "text")], None, context=context + ) + assert await provider.set_response_snapshot("same", _response("same"), ttl_seconds=10, context=contexts[0]) + assert await provider.purge_expired(now=datetime.now(timezone.utc) + timedelta(seconds=11)) == 1 + with pytest.raises(KeyError): + await provider.get_response("same", context=contexts[0]) + assert await provider.get_history_item_ids(None, "conversation", 100, context=contexts[0]) == [] + for context in contexts[1:]: + assert await provider.get_history_item_ids(None, "conversation", 100, context=context) == ["input"] + assert await provider.get_input_items("same", context=context) == [_input_item("input", "text")] + + # =========================================================================== # Create # =========================================================================== diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_observability.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_observability.py index d578db78615b..6767d6ec746c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_observability.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_observability.py @@ -8,6 +8,7 @@ from azure.ai.agentserver.responses.hosting._observability import ( InMemoryCreateSpanHook, + _initial_create_span_tags, build_create_otel_attrs, build_create_span_tags, build_platform_server_header, @@ -16,6 +17,11 @@ ) +def test_observability__initial_span_tags_use_foundry_provider() -> None: + assert _initial_create_span_tags()["gen_ai.provider.name"] == "AzureAI Hosted Agents" + assert _initial_create_span_tags()["microsoft.foundry"] == "True" + + def test_observability__build_platform_server_header_includes_extra_identity() -> None: value = build_platform_server_header( sdk_name="azure-ai-agentserver-responses", @@ -58,6 +64,7 @@ def test_observability__build_create_span_tags_uses_agent_name_and_model() -> No assert tags["service.name"] == "azure.ai.agentserver" assert tags["gen_ai.operation.name"] == "invoke_agent" assert tags["gen_ai.provider.name"] == "AzureAI Hosted Agents" + assert tags["microsoft.foundry"] == "True" assert tags["gen_ai.response.id"] == "resp_abc" assert tags["gen_ai.request.model"] == "gpt-4o-mini" assert tags["gen_ai.agent.name"] == "agent-one" @@ -170,6 +177,7 @@ def test_observability__build_create_otel_attrs_includes_all_fields() -> None: assert attrs["gen_ai.response.id"] == "resp_1" assert attrs["service.name"] == "azure.ai.agentserver" assert attrs["gen_ai.provider.name"] == "AzureAI Hosted Agents" + assert attrs["microsoft.foundry"] == "True" assert attrs["gen_ai.operation.name"] == "invoke_agent" assert attrs["gen_ai.request.model"] == "gpt-4o" assert attrs["gen_ai.conversation.id"] == "conv_x" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_request_history_resolution.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_request_history_resolution.py index aa21779767a6..e187964dfeac 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_request_history_resolution.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_request_history_resolution.py @@ -424,6 +424,7 @@ async def handler(request, context, cancellation_signal): async def test_task_handoff_keeps_fresh_cache_but_resets_recovered_lifetime(monkeypatch, mode): from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as resilient from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput, RuntimeRefs + from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id provider = _provider() owner = _context(provider, ["history"]) @@ -446,7 +447,7 @@ async def test_task_handoff_keeps_fresh_cache_but_resets_recovered_lifetime(monk assert "history" not in str(params) monkeypatch.setitem( resilient._RUNTIME_REFS, - "response", + derive_lifecycle_id("response", "user"), RuntimeRefs(record=MagicMock(), context=owner, parsed=parsed, cancel=asyncio.Event()), ) seen = [] diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_input.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_input.py index c30163cc6e5e..82f8ef18b157 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_input.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_input.py @@ -18,6 +18,7 @@ ResilientResponseInput, RuntimeRefs, platform_context_from_params, + user_id_key_from_params, ) from azure.ai.agentserver.responses.models._generated import AgentReference, CreateResponse @@ -113,6 +114,27 @@ def test_runtime_refs_never_serialized() -> None: assert refs.record is not None and refs.context is not None +def test_incarnation_round_trip_is_private_and_never_regenerated_on_recovery() -> None: + original = _make_input(incarnation_id="a" * 32) + params = original.to_task_input() + assert params["response_incarnation_id"] == "a" * 32 + assert "response_incarnation_id" not in params["request"] + restored = ResilientResponseInput.from_task_input(params) + assert restored.incarnation_id == original.incarnation_id + assert restored.to_task_input() == params + legacy = _make_input().to_task_input() + assert "response_incarnation_id" not in legacy + assert ResilientResponseInput.from_task_input(legacy).incarnation_id is None + + +@pytest.mark.parametrize("invalid", [None, "", "short", "g" * 32, 42, {}]) +def test_malformed_incarnation_fails_closed(invalid) -> None: + params = _make_input().to_task_input() + params["response_incarnation_id"] = invalid + with pytest.raises(ValueError, match="incarnation"): + ResilientResponseInput.from_task_input(params) + + # --------------------------------------------------------------------------- # # FR-002f — fail-closed on malformed persisted input # --------------------------------------------------------------------------- # @@ -159,3 +181,35 @@ def test_isolation_absent_keys_default_to_none() -> None: iso = resilient.platform_context() assert iso.user_id_key is None assert iso.call_id is None + + +@pytest.mark.parametrize("invalid", [[], ["owner"], {}, {"user": "owner"}, 1, True, False, 1.5]) +@pytest.mark.parametrize("reader", ["context", "input", "constructor", "mutated-input", "mutated-context"]) +def test_invalid_persisted_user_partition_is_rejected_before_identity_creation(invalid, reader) -> None: + params = _make_input().to_task_input() + params["user_id_key"] = invalid + with pytest.raises(ValueError, match="user_id_key"): + if reader == "context": + platform_context_from_params(params) + elif reader == "input": + ResilientResponseInput.from_task_input(params) + elif reader == "constructor": + _make_input(user_id_key=invalid) + else: + value = _make_input() + value.user_id_key = invalid + if reader == "mutated-context": + value.platform_context() + else: + value.to_task_input() + + +@pytest.mark.parametrize("user", ["owner", "", None]) +def test_valid_user_partition_is_never_coerced(user) -> None: + value = _make_input(user_id_key=user) + params = value.to_task_input() + assert user_id_key_from_params(params) == user + assert value.platform_context().user_id_key == user + assert platform_context_from_params(params).user_id_key == user + assert ResilientResponseInput.from_task_input(params).user_id_key == user + assert user_id_key_from_params({}) is None diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_orchestrator.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_orchestrator.py index 30b8c91e9199..3f7932563956 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_orchestrator.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_resilient_orchestrator.py @@ -327,13 +327,14 @@ async def test_cancel_bridge_propagates(self) -> None: ctx.is_steered_turn = False # Spec 016 FR-020: was_steered renamed ctx.pending_input_count = 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count ctx._cancellation_signal = asyncio.Event() + ctx.cancel = ctx._cancellation_signal ctx.shutdown = asyncio.Event() ctx.task_id = "test-task-id" ctx.input = { "response_id": "resp_cancel", "request": {"input": "hi"}, "_record_ref": MagicMock(), - "_context_ref": MagicMock(), + "_context_ref": MagicMock(shutdown=asyncio.Event()), "_parsed_ref": MagicMock(), "_cancel_ref": cancel_signal, "_runtime_state_ref": MagicMock(), diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_delete_recovery.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_delete_recovery.py new file mode 100644 index 000000000000..81b6cb66b53b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_delete_recovery.py @@ -0,0 +1,2241 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Crash-abandoned admission and durable, retryable scoped deletion regressions.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlparse + +import pytest +from anyio import CancelScope, lowlevel +from starlette.requests import Request + +from azure.ai.agentserver.core.streaming import EventStreamNotFoundError +from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry +from azure.ai.agentserver.core.tasks import TaskManagerNotInitialized +from azure.ai.agentserver.core.tasks._attachments import _resolve_input_storage +from azure.ai.agentserver.core.tasks._client import HostedTaskProvider +from azure.ai.agentserver.core.tasks._context import _ExitForRecovery +from azure.ai.agentserver.core.tasks._exceptions_internal import _HostedConflict +from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider +from azure.ai.agentserver.core.tasks._manager import TaskManager, _SCHEMA_VERSION, _SCHEMA_VERSION_KEY, set_task_manager +from azure.ai.agentserver.core.tasks._models import TaskCreateRequest, TaskInfo, TaskPatchRequest +from azure.ai.agentserver.responses import ( + PlatformContext, + ResponseContext, + ResponsesAgentServerHost, + ResponsesServerOptions, +) +from azure.ai.agentserver.responses.hosting import _response_task_lifecycle as lifecycle +from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as resilience +from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput, RuntimeRefs +from azure.ai.agentserver.responses.hosting._runtime_state import _RuntimeState +from azure.ai.agentserver.responses.hosting._execution_context import _ExecutionContext +from azure.ai.agentserver.responses.hosting._observability import CreateSpan +from azure.ai.agentserver.responses.hosting._orchestrator import _PipelineState +from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id +from azure.ai.agentserver.responses.models._generated import CreateResponse, ResponseObject +from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags +from azure.ai.agentserver.responses.streaming import ResponseEventStream +from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider +from azure.ai.agentserver.responses.store._file import FileResponseStore +from tests.contract.test_user_isolation_enforcement import _AsyncAsgiClient, _noop_handler +from tests.unit.test_stream_lifecycle_cleanup import RESPONSE_ID, _host, _request, registry + + +@pytest.fixture +def task_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> LocalFileTaskProvider: + provider = LocalFileTaskProvider(base_dir=tmp_path / "tasks") + + manager = TaskManager(_host().config, provider=provider) + monkeypatch.setattr(lifecycle, "get_task_manager", lambda: manager) + return provider + + +def _snapshot() -> ResponseObject: + return ResponseObject( + {"id": RESPONSE_ID, "object": "response", "status": "completed", "background": True, "model": "m", "output": []} + ) + + +def _delete_request() -> Request: + request = _request() + request.scope["method"] = "DELETE" + request.scope["path_params"] = {"response_id": RESPONSE_ID} + return request + + +def _get_request(*, stream: bool = False, user: str = "owner") -> Request: + request = _request(user=user) + request.scope["method"] = "GET" + request.scope["path_params"] = {"response_id": RESPONSE_ID} + request.scope["query_string"] = b"stream=true" if stream else b"" + return request + + +async def _read_stream(response: Any) -> bytes: + chunks = [chunk.encode() if isinstance(chunk, str) else chunk async for chunk in response.body_iterator] + return b"".join(chunks) + + +def _cached_refs(host: ResponsesAgentServerHost, params: dict[str, Any]) -> RuntimeRefs: + record, context = resilience._reconstruct_from_params( + params=params, + response_id=RESPONSE_ID, + provider=host._endpoint._provider, + runtime_state=host._endpoint._runtime_state, + runtime_options=host._endpoint._runtime_options, + ) + return RuntimeRefs( + record=record, + context=context, + parsed=CreateResponse(params["request"]), + cancel=asyncio.Event(), + runtime_state=host._endpoint._runtime_state, + ) + + +@pytest.mark.parametrize("entry_mode", ["queued", "recovered", "fresh", "resumed"]) +async def test_deleted_same_process_callback_releases_only_its_incarnation_refs( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, entry_mode: str +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "a" * 32} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + successor = lifecycle._input_fence_key(RESPONSE_ID, "owner", "b" * 32) + foreign = lifecycle._input_fence_key(RESPONSE_ID, "other", "a" * 32) + refs = _cached_refs(host, params) + others = {successor: RuntimeRefs(), foreign: RuntimeRefs()} + resilience._RUNTIME_REFS.update({key: refs, **others}) + await host._endpoint._provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + ctx = _task_context(info, entry_mode=entry_mode) + ctx.input = params + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert resilience._RUNTIME_REFS == others + + +@pytest.mark.parametrize( + "settlement", ["malformed", "stale", "mark-failed", "missing-provider", "legacy-deleted", "raced-fence"] +) +async def test_terminal_pre_handler_exit_releases_exact_cached_refs( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, settlement: str +) -> None: + host = _host() + disposition = "mark-failed" if settlement == "mark-failed" else "re-invoke" + info = await _durable_task(task_store, host, disposition=disposition) + params = _params(disposition=disposition) + incarnation = None if settlement == "legacy-deleted" else "a" * 32 + if incarnation is not None: + params["response_incarnation_id"] = incarnation + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", incarnation) + others = { + lifecycle._input_fence_key(RESPONSE_ID, "owner", "b" * 32): RuntimeRefs(), + lifecycle._input_fence_key(RESPONSE_ID, "other", incarnation): RuntimeRefs(), + } + resilience._RUNTIME_REFS.update({key: _cached_refs(host, params), **others}) + if settlement == "malformed": + params.pop("request") + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + if settlement == "stale": + await task_store.update( + info.id, TaskPatchRequest(payload={"input": {**_params(), "response_incarnation_id": "b" * 32}}) + ) + if settlement in {"malformed", "mark-failed"}: + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + elif settlement == "legacy-deleted": + await host._endpoint._runtime_state.mark_deleted(RESPONSE_ID, "owner") + elif settlement == "raced-fence": + get = task_store.get + first = True + + async def raced_get(task_id: str) -> TaskInfo: + nonlocal first + snapshot = await get(task_id) + if first: + first = False + await task_store.update(info.id, TaskPatchRequest(payload={lifecycle._DELETED_INPUT_IDS: [key]})) + return snapshot + + monkeypatch.setattr(task_store, "get", raced_get) + ctx = _task_context(info, disposition=disposition) + ctx.input = params + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert resilience._RUNTIME_REFS == others + + +@pytest.mark.parametrize("failure", ["busy", "ownership-error", "admitted-deferral", "admitted-error", "recheck-error"]) +async def test_nonterminal_admission_failure_preserves_cached_refs( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "a" * 32} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, params) + resilience._RUNTIME_REFS[key] = refs + ctx = _task_context(info) + ctx.input = params + if failure == "busy": + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + assert isinstance( + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx), _ExitForRecovery + ) + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + elif failure == "ownership-error": + monkeypatch.setattr(task_store, "get", AsyncMock(side_effect=OSError("ownership lookup unavailable"))) + with pytest.raises(OSError, match="ownership lookup unavailable"): + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + else: + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + if failure == "admitted-deferral": + result = _ExitForRecovery() + monkeypatch.setattr(orchestrator, "_execute_admitted_task", AsyncMock(return_value=result)) + assert await orchestrator._execute_in_task(ctx) is result + else: + if failure == "recheck-error": + snapshot = await task_store.get(info.id) + monkeypatch.setattr( + task_store, "get", AsyncMock(side_effect=[snapshot, OSError("ownership lookup unavailable")]) + ) + else: + monkeypatch.setattr( + orchestrator, + "_execute_admitted_task", + AsyncMock(side_effect=OSError("ownership lookup unavailable")), + ) + with pytest.raises(OSError, match="ownership lookup unavailable"): + await orchestrator._execute_in_task(ctx) + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner", recovery=True) + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + assert resilience._RUNTIME_REFS[key] is refs + + +async def test_terminal_ref_cleanup_preserves_a_replacement_at_the_same_legacy_key( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = _params() + key = lifecycle._input_fence_key(RESPONSE_ID, "owner") + resilience._RUNTIME_REFS[key] = _cached_refs(host, params) + params.pop("request") + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + replacement = RuntimeRefs() + + async def settle(response_id: str, boundary: dict[str, Any]) -> None: + resilience._RUNTIME_REFS[key] = replacement + + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + monkeypatch.setattr(orchestrator, "_persist_crash_failed", settle) + ctx = _task_context(info) + ctx.input = params + await orchestrator._execute_in_task(ctx) + assert resilience._RUNTIME_REFS[key] is replacement + + +@pytest.mark.parametrize("replacement", [False, True]) +async def test_handler_finalization_releases_only_the_refs_it_started_with( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, replacement: bool +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "a" * 32} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, params) + resilience._RUNTIME_REFS[key] = refs + next_refs = RuntimeRefs() + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + + async def run(**kwargs: Any) -> None: + if replacement: + resilience._RUNTIME_REFS[key] = next_refs + kwargs["record"].transition_to("completed") + + monkeypatch.setattr(host._endpoint._orchestrator, "_run_resilient_stream_body", run) + ctx = _task_context(info) + ctx.input = params + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + if replacement: + assert resilience._RUNTIME_REFS[key] is next_refs + else: + assert key not in resilience._RUNTIME_REFS + + +async def test_invalid_incarnation_never_guesses_a_legacy_or_successor_cache_key( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "invalid"} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + others = { + lifecycle._input_fence_key(RESPONSE_ID, "owner"): RuntimeRefs(), + lifecycle._input_fence_key(RESPONSE_ID, "owner", "b" * 32): RuntimeRefs(), + lifecycle._input_fence_key(RESPONSE_ID, "other"): RuntimeRefs(), + } + resilience._RUNTIME_REFS.update(others) + ctx = _task_context(info) + ctx.input = params + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert resilience._RUNTIME_REFS == others + + +@pytest.mark.parametrize("runtime_record", [False, True]) +@pytest.mark.parametrize("original_stream", [False, True]) +@pytest.mark.parametrize("backend", ["memory", "file"]) +async def test_failed_provider_delete_reads_never_allocate_replacement_replay( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + runtime_record: bool, + original_stream: bool, + backend: str, +) -> None: + host = _host() + provider = host._endpoint._provider + if backend == "file": + registry.use_file_backed_replay( + storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"] + ) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + if original_stream: + replay = await registry.get_or_create(identifier) + await replay.emit({"sequence_number": 0, "type": "response.completed"}, close=True) + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + if runtime_record: + record = _execution() + record.mode_flags.stream = original_stream + record.response_created_signal.set() + await host._endpoint._runtime_state.add(record) + delete = provider.delete_response + monkeypatch.setattr(provider, "delete_response", AsyncMock(side_effect=OSError("provider deletion unavailable"))) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 500 + retained = await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") + assert retained is not None + with pytest.raises(EventStreamNotFoundError): + await registry.get(identifier) + create = AsyncMock(side_effect=AssertionError("GET must not allocate replay")) + monkeypatch.setattr(registry, "get_or_create", create) + try: + response = await host._endpoint.handle_get(_get_request(stream=True)) + if response.status_code == 200: + await asyncio.wait_for(_read_stream(response), 2) + assert response.status_code in {400, 404} + create.assert_not_awaited() + assert (await host._endpoint.handle_get(_get_request(stream=True, user="other"))).status_code == 404 + snapshot = await host._endpoint.handle_get(_get_request()) + assert snapshot.status_code == 200 + assert json.loads(snapshot.body)["status"] == "completed" + assert await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") is retained + assert not await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + with pytest.raises(EventStreamNotFoundError): + await registry.get(identifier) + monkeypatch.setattr(provider, "delete_response", delete) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("runtime_record", [False, True]) +async def test_retained_delete_reads_preserve_existing_replay_without_creating_a_stream( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + runtime_record: bool, +) -> None: + host = _host() + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + await replay.emit({"sequence_number": 0, "type": "response.completed"}, close=True) + await host._endpoint._provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + if runtime_record: + record = _execution() + record.subject = replay + await host._endpoint._runtime_state.add(record) + delete = registry.delete + monkeypatch.setattr(registry, "delete", AsyncMock(side_effect=OSError("replay deletion unavailable"))) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 500 + create = AsyncMock(side_effect=AssertionError("GET must not allocate replay")) + monkeypatch.setattr(registry, "get_or_create", create) + try: + response = await host._endpoint.handle_get(_get_request(stream=True)) + assert response.status_code == 200 + assert b"response.completed" in await asyncio.wait_for(_read_stream(response), 2) + assert await registry.get(identifier) is replay + create.assert_not_awaited() + finally: + await delete(identifier) + + +@pytest.mark.parametrize("settlement", ["malformed", "mark-failed"]) +async def test_early_recovery_settlement_retires_stale_execution_and_serves_terminal_snapshot( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, settlement: str +) -> None: + host = _host() + state = host._endpoint._runtime_state + disposition = "mark-failed" if settlement == "mark-failed" else "re-invoke" + info = await _durable_task(task_store, host, disposition=disposition) + params = _params(disposition=disposition) + if settlement == "malformed": + params["response_incarnation_id"] = "invalid" + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + stored = _snapshot() + stored["status"] = "in_progress" + await host._endpoint._provider.create_response(stored, [], None, context=PlatformContext(user_id_key="owner")) + stale = _execution(status="in_progress") + stale.execution_task = asyncio.create_task(asyncio.sleep(0)) + await stale.execution_task + await state.add(stale) + foreign = _execution(user="other", status="in_progress") + await state.add(foreign) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + await replay.emit({"sequence_number": 0, "type": "response.created"}) + ctx = _task_context(info, disposition=disposition) + ctx.input = params + try: + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + response = await host._endpoint.handle_get(_get_request()) + assert response.status_code == 200 + assert json.loads(response.body)["status"] == "failed" + assert await state.get(RESPONSE_ID, "owner") is not stale + assert await state.get(RESPONSE_ID, "other") is foreign + assert (await host._endpoint.handle_create(_request())).status_code == 409 # Retained replay still owns the ID. + assert [event["type"] async for event in replay.subscribe()][-1] == "response.failed" + await registry.delete(identifier) + assert await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("failure", ["cancel", "anyio", "error"]) +async def test_early_recovery_interruption_does_not_retain_stale_execution_or_reservation( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + host = _host() + state = host._endpoint._runtime_state + info = await _durable_task(task_store, host) + stored = _snapshot() + stored["status"] = "in_progress" + await host._endpoint._provider.create_response(stored, [], None, context=PlatformContext(user_id_key="owner")) + stale = _execution(status="in_progress") + stale.execution_task = asyncio.create_task(asyncio.sleep(0)) + await stale.execution_task + await state.add(stale) + foreign = _execution(user="other", status="in_progress") + await state.add(foreign) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + execute = orchestrator._execute_admitted_task + scope = CancelScope() + + async def interrupted(ctx: Any) -> None: + assert await state.get(RESPONSE_ID, "owner") is None + assert not await state.reserve(RESPONSE_ID, "owner") + assert not await state.begin_deletion(RESPONSE_ID, "owner") + if failure == "anyio": + scope.cancel() + await lowlevel.checkpoint() + if failure == "error": + raise OSError("recovery interrupted before reconstruction") + raise asyncio.CancelledError() + + monkeypatch.setattr(orchestrator, "_execute_admitted_task", interrupted) + ctx = _task_context(info) + if failure == "anyio": + with scope: + await orchestrator._execute_in_task(ctx) + assert scope.cancelled_caught + else: + with pytest.raises(OSError if failure == "error" else asyncio.CancelledError): + await orchestrator._execute_in_task(ctx) + assert await state.get(RESPONSE_ID, "owner") is None + assert await state.get(RESPONSE_ID, "other") is foreign + assert not await state.is_deleted(RESPONSE_ID, "owner") + assert await state.reserve(RESPONSE_ID, "owner", recovery=True) + await state.release_reservation(RESPONSE_ID, "owner") + assert await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + snapshot = await host._endpoint.handle_get(_get_request()) + assert snapshot.status_code == 200 + assert json.loads(snapshot.body)["model"] == "m" + + async def handler(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + async def events() -> Any: + stream = ResponseEventStream(response_id=context.response_id, model="m") + yield stream.emit_created() + yield stream.emit_completed() + + return events() + + host.response_handler(handler) + monkeypatch.setattr(orchestrator, "_execute_admitted_task", execute) + await orchestrator._execute_in_task(ctx) + snapshot = await host._endpoint.handle_get(_get_request()) + assert snapshot.status_code == 200 + assert json.loads(snapshot.body)["status"] == "completed" + assert await state.get(RESPONSE_ID, "other") is foreign + + +@pytest.mark.parametrize("status", ["in_progress", "completed"]) +async def test_stale_recovery_callback_cannot_retire_a_newer_incarnation_record( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, status: str +) -> None: + host = _host() + state = host._endpoint._runtime_state + info = await _durable_task(task_store, host) + old = {**_params(), "response_incarnation_id": "a" * 32} + new = {**_params(), "response_incarnation_id": "b" * 32} + await task_store.update(info.id, TaskPatchRequest(payload={"input": old})) + stale = _execution(status="in_progress") + stale.execution_task = asyncio.create_task(asyncio.sleep(0)) + await stale.execution_task + await state.add(stale) + replacement = _execution(status=status) + get = task_store.get + first = True + + async def raced_get(task_id: str) -> TaskInfo: + nonlocal first + snapshot = await get(task_id) + if first: + first = False + await task_store.update(info.id, TaskPatchRequest(payload={"input": new})) + assert await state.add(replacement, expected_record=stale) + return snapshot + + monkeypatch.setattr(task_store, "get", raced_get) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + admitted = AsyncMock() + monkeypatch.setattr(orchestrator, "_execute_admitted_task", admitted) + ctx = _task_context(info) + ctx.input = old + assert isinstance(await orchestrator._execute_in_task(ctx), _ExitForRecovery) + admitted.assert_not_awaited() + assert await state.get(RESPONSE_ID, "owner") is replacement + assert not await state.is_deleted(RESPONSE_ID, "owner") + + +def _execution(*, user: str = "owner", status: Any = "completed") -> ResponseExecution: + return ResponseExecution( + response_id=RESPONSE_ID, + user_id_key=user, + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + status=status, + response=_snapshot() if status == "completed" else None, + ) + + +def _params(*, user: str = "owner", response_id: str = RESPONSE_ID, disposition: str = "re-invoke") -> dict[str, Any]: + return ResilientResponseInput( + request=CreateResponse(model="m", stream=True, store=True, background=True, input="hi"), + response_id=response_id, + disposition=disposition, + user_id_key=user, + ).to_task_input() + + +async def _durable_task( + provider: LocalFileTaskProvider, + host: ResponsesAgentServerHost, + *, + user: str = "owner", + disposition: str = "re-invoke", + multi_turn: bool = False, + attached: bool = False, + queued: bool = False, +) -> TaskInfo: + task_id = derive_lifecycle_id(f"task-{RESPONSE_ID}", user) + params = _params(user=user, disposition=disposition) + payload: dict[str, Any] = {"input": params, _SCHEMA_VERSION_KEY: _SCHEMA_VERSION} + attachments = None + if attached: + _, slot = _resolve_input_storage(params, threshold_bytes=0, key_for_attachment="input", task_id=task_id) + payload["input"] = slot + attachments = {"input": params} + if multi_turn: + payload["last_input_id"] = derive_lifecycle_id(RESPONSE_ID, user) + if queued: + payload["input"] = _params(user=user, response_id="another-response") + payload["last_input_id"] = derive_lifecycle_id("another-response", user) + _, slot = _resolve_input_storage(params, threshold_bytes=0, key_for_attachment="input", task_id=task_id) + payload["steering"] = {"pending_inputs": [slot]} + attachments = {"input": params} + name = host._endpoint._response_task_names()[int(multi_turn)] + return await provider.create( + TaskCreateRequest( + id=task_id, + agent_name=host.config.agent_name or "default", + session_id=host.config.session_id or "local", + title="Response lifecycle regression", + payload=payload, + attachments=attachments, + tags={"task_name": name}, + source=TaskManager._build_source(name), + ) + ) + + +def _task_context(info: TaskInfo, *, disposition: str = "re-invoke", entry_mode: str = "recovered") -> Any: + return SimpleNamespace( + task_id=info.id, + input=_params(disposition=disposition), + entry_mode=entry_mode, + is_steered_turn=False, + pending_input_count=0, + cancel=asyncio.Event(), + shutdown=asyncio.Event(), + ) + + +async def _newer_tasks(provider: LocalFileTaskProvider, host: ResponsesAgentServerHost, name: str) -> None: + for index in range(125): + await provider.create( + TaskCreateRequest( + id=f"newer-task-{index}", + agent_name=host.config.agent_name or "default", + session_id=host.config.session_id or "local", + title="Newer response task", + payload={"input": _params(response_id=f"newer-response-{index}"), _SCHEMA_VERSION_KEY: _SCHEMA_VERSION}, + tags={"task_name": name}, + source=TaskManager._build_source(name), + ) + ) + + +@pytest.fixture(params=["local", "hosted"]) +async def complete_listing(task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, request: Any) -> Any: + """Exercise local enumeration and real hosted opaque-cursor pagination.""" + if request.param == "local": + yield SimpleNamespace(provider=task_store, requests=[], kind="local") + return + hosted = HostedTaskProvider(project_endpoint="https://example.invalid", credential=AsyncMock()) + requests = [] + cursors: dict[str, str] = {} + + async def send(http_request: Any) -> Any: + query = parse_qs(urlparse(http_request.url).query) + requests.append(query) + after = query.get("after", [None])[0] + if after is not None: + assert after in cursors # Task IDs must never replace service cursors. + page = await task_store.list( + agent_name=query["agent_name"][0], + session_id=query["session_id"][0], + tag={"task_name": query["tag.task_name"][0]}, + source_type=query["source_type"][0], + limit=7, + after=cursors.get(after), + ) + body = {"data": [info.to_dict() for info in page], "has_more": len(page) == 7} + if body["has_more"]: + cursor = f"opaque-page-{len(requests)}" + cursors[cursor] = page[-1].id + body["last_id"] = cursor + return SimpleNamespace(status_code=200, headers={}, body=lambda: json.dumps(body).encode()) + + monkeypatch.setattr(hosted, "_send", send) + provider = SimpleNamespace(list=hosted.list, get=task_store.get, update=task_store.update) + manager = TaskManager(_host().config, provider=provider) + monkeypatch.setattr(lifecycle, "get_task_manager", lambda: manager) + try: + yield SimpleNamespace(provider=provider, hosted=hosted, requests=requests, kind="hosted") + finally: + await hosted.close() + + +@pytest.mark.parametrize("multi_turn", [False, True]) +async def test_complete_task_listing_preserves_older_owner_and_fences_delete_after_restart( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + complete_listing: Any, + tmp_path: Path, + multi_turn: bool, +) -> None: + host = _host() + info = await _durable_task(task_store, host, multi_turn=multi_turn, attached=True) + name = host._endpoint._response_task_names()[int(multi_turn)] + await _newer_tasks(task_store, host, name) + first_page = await task_store.list(tag={"task_name": name}, limit=20) + assert len(first_page) == 20 + assert info.id not in {item.id for item in first_page} + storage = tmp_path / "streams" + first = _StreamsRegistry() + first.use_file_backed_replay(storage_dir=storage, cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + abandoned = await first.get_or_create(identifier) + abandoned._cleanup_locks() + registry.use_file_backed_replay(storage_dir=storage, cursor_fn=lambda event: event["sequence_number"]) + replay = await registry.get(identifier) + try: + assert not await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert replay._path.exists() + assert await registry.get(identifier) is replay + assert [item.id for item in await lifecycle._response_tasks(RESPONSE_ID, "owner", (name,))] == [info.id] + await host._endpoint._provider.create_response( + _snapshot(), [], None, context=PlatformContext(user_id_key="owner") + ) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + assert lifecycle._DELETED_INPUT_IDS in (await task_store.get(info.id)).payload + for item in first_page: + assert (await task_store.get(item.id)).payload == item.payload + restarted = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=False), store=host._endpoint._provider + ) + restarted.response_handler(_noop_handler) + orchestrator = restarted._endpoint._orchestrator._resilient_orchestrator + admitted = AsyncMock() + orchestrator._execute_admitted_task = admitted + await orchestrator._execute_in_task(_task_context(info)) + admitted.assert_not_awaited() + assert await restarted._endpoint._runtime_state.list_records() == [] + with pytest.raises(KeyError): + await restarted._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + if complete_listing.kind == "hosted": + assert any("after" in query for query in complete_listing.requests) + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("operation", ["orphan", "delete"]) +async def test_task_scan_failure_never_proves_absence_or_completes_delete( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + complete_listing: Any, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + operation: str, +) -> None: + host = _host() + info = await _durable_task(task_store, host) + await _newer_tasks(task_store, host, host._endpoint._response_task_names()[0]) + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + if operation == "delete": + await host._endpoint._provider.create_response( + _snapshot(), [], None, context=PlatformContext(user_id_key="owner") + ) + original = task_store._read_task if complete_listing.kind == "local" else complete_listing.hosted._send + calls = 0 + + def read(path: Path) -> Any: + nonlocal calls + calls += 1 + if calls == 25: + raise OSError("task scan unavailable") + return original(path) + + async def send(http_request: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 3: + raise OSError("task scan unavailable") + return await original(http_request) + + if complete_listing.kind == "local": + monkeypatch.setattr(task_store, "_read_task", read) + else: + monkeypatch.setattr(complete_listing.hosted, "_send", send) + try: + if operation == "orphan": + with pytest.raises(OSError, match="task scan unavailable"): + await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + else: + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 500 + assert not await host._endpoint._runtime_state.is_deleted(RESPONSE_ID, "owner") + assert ( + await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + )["id"] == RESPONSE_ID + assert replay._path.exists() + assert lifecycle._DELETED_INPUT_IDS not in (await task_store.get(info.id)).payload + if operation == "delete": + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + else: + assert not await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("incarnation", [None, "", "invalid", "A" * 32, 7, ["a" * 32]]) +@pytest.mark.parametrize("attached", [False, True]) +async def test_malformed_current_incarnation_settles_failed_without_execution( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, incarnation: Any, attached: bool +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": incarnation} + _, slot = _resolve_input_storage( + params, threshold_bytes=0 if attached else 1000000, key_for_attachment="input", task_id=info.id + ) + await task_store.update( + info.id, TaskPatchRequest(payload={"input": slot}, attachments={"input": params} if attached else None) + ) + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + await replay.emit({"type": "response.created", "sequence_number": 0}) + ctx = _task_context(info) + ctx.input = params + try: + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + stored = await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert stored["status"] == "failed" + events = [event async for event in replay.subscribe()] + assert events[-1]["type"] == "response.failed" + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("invalid", [[], {}, 1, True]) +@pytest.mark.parametrize("entry", ["callback", "admitted"]) +async def test_invalid_user_without_proven_identity_never_touches_any_partition( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch, caplog, invalid, entry +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "a" * 32, "user_id_key": invalid} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + identifiers = [] + for user in ("owner", "other", None): + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key=user)) + identifier = derive_lifecycle_id(RESPONSE_ID, user) + identifiers.append(identifier) + replay = await registry.get_or_create(identifier) + await replay.emit({"type": "response.created", "sequence_number": 0}) + foreign_key = lifecycle._input_fence_key(RESPONSE_ID, "other", "a" * 32) + foreign = _cached_refs(host, {**_params(user="other"), "response_incarnation_id": "a" * 32}) + resilience._RUNTIME_REFS[foreign_key] = foreign + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + persist = AsyncMock() + reserve = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", persist) + monkeypatch.setattr(host._endpoint._runtime_state, "reserve", reserve) + ctx = _task_context(info) + ctx.input = params + ctx.input_id = derive_lifecycle_id(RESPONSE_ID, "owner") + try: + result = ( + await orchestrator._execute_in_task(ctx) + if entry == "callback" + else await orchestrator._execute_admitted_task(ctx) + ) + assert result is None + persist.assert_not_awaited() + reserve.assert_not_awaited() + assert resilience._RUNTIME_REFS == {foreign_key: foreign} + assert "partition" in caplog.text + for user, identifier in zip(("owner", "other", None), identifiers): + stored = await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key=user)) + assert stored["status"] == "in_progress" + assert await (await registry.get(identifier)).last_cursor() == 0 + assert host._endpoint._runtime_state._reservations == set() + finally: + for identifier in identifiers: + await registry.delete(identifier) + + +@pytest.mark.parametrize("invalid", [[], {}, 1, False]) +@pytest.mark.parametrize("owner", ["owner", "", None]) +@pytest.mark.parametrize("attached", [False, True]) +async def test_invalid_user_with_original_task_refs_settles_only_proven_partition( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch, invalid, owner, attached +) -> None: + host = _host() + info = await _durable_task(task_store, host, user=owner) + original = {**_params(user=owner), "response_incarnation_id": "a" * 32, "call_id": "original-call"} + params = {**original, "user_id_key": invalid, "call_id": "untrusted-call"} + _, slot = _resolve_input_storage( + params, threshold_bytes=0 if attached else 1000000, key_for_attachment="input", task_id=info.id + ) + await task_store.update( + info.id, TaskPatchRequest(payload={"input": slot}, attachments={"input": params} if attached else None) + ) + refs = _cached_refs(host, original) + identifier = derive_lifecycle_id(RESPONSE_ID, owner) + refs.record.resilient_task_run = SimpleNamespace(task_id=info.id, input_id=identifier) + key = lifecycle._input_fence_key(RESPONSE_ID, owner, "a" * 32) + others = { + lifecycle._input_fence_key(RESPONSE_ID, owner, "b" * 32): RuntimeRefs(), + lifecycle._input_fence_key(RESPONSE_ID, "foreign", "a" * 32): RuntimeRefs(), + } + resilience._RUNTIME_REFS.update({key: refs, **others}) + for user in (owner, "foreign"): + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key=user)) + replay = await registry.get_or_create(identifier) + await replay.emit({"type": "response.created", "sequence_number": 0}) + ctx = _task_context(info) + ctx.input = params + ctx.input_id = identifier + reads = AsyncMock(wraps=host._endpoint._provider.get_response) + monkeypatch.setattr(host._endpoint._provider, "get_response", reads) + try: + assert await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) is None + assert reads.await_args_list[0].kwargs["context"].user_id_key == owner + assert reads.await_args_list[0].kwargs["context"].call_id == "original-call" + stored = await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key=owner)) + assert stored["status"] == "failed" + assert [event async for event in replay.subscribe()][-1]["type"] == "response.failed" + foreign = await host._endpoint._provider.get_response( + RESPONSE_ID, context=PlatformContext(user_id_key="foreign") + ) + assert foreign["status"] == "in_progress" + assert resilience._RUNTIME_REFS == others + assert host._endpoint._runtime_state._reservations == set() + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("ownership", ["deleted", "stale", "local-deleted", "terminal"]) +async def test_invalid_user_trusted_callback_still_obeys_current_input_and_deletion_guards( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch, ownership +) -> None: + host = _host() + info = await _durable_task(task_store, host) + original = {**_params(), "response_incarnation_id": "a" * 32} + params = {**original, "user_id_key": []} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, original) + refs.record.resilient_task_run = SimpleNamespace(task_id=info.id, input_id=identifier) + successor = lifecycle._input_fence_key(RESPONSE_ID, "owner", "b" * 32) + others = {successor: RuntimeRefs()} + resilience._RUNTIME_REFS.update({key: refs, **others}) + if ownership == "deleted": + await task_store.update(info.id, TaskPatchRequest(payload={lifecycle._DELETED_INPUT_IDS: [key]})) + elif ownership == "stale": + await task_store.update( + info.id, TaskPatchRequest(payload={"input": {**original, "response_incarnation_id": "b" * 32}}) + ) + elif ownership == "terminal": + await task_store.update(info.id, TaskPatchRequest(status="completed")) + else: + await host._endpoint._runtime_state.mark_deleted(RESPONSE_ID, "owner") + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + persist = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", persist) + ctx = _task_context(info) + ctx.input = params + ctx.input_id = identifier + await orchestrator._execute_in_task(ctx) + persist.assert_not_awaited() + assert resilience._RUNTIME_REFS == others + assert registry._slots == {} + assert host._endpoint._runtime_state._reservations == set() + + +@pytest.mark.parametrize("proof", ["task", "input", "record", "context", "runtime", "nonce", "legacy"]) +async def test_invalid_user_never_guesses_missing_or_mismatched_runtime_proof( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch, proof +) -> None: + host = _host() + info = await _durable_task(task_store, host) + original = {**_params(), "response_incarnation_id": "a" * 32} + params = {**original, "user_id_key": {}} + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, original) + refs.record.resilient_task_run = SimpleNamespace(task_id=info.id, input_id=identifier) + if proof == "task": + refs.record.resilient_task_run.task_id = "foreign-task" + elif proof == "input": + refs.record.resilient_task_run.input_id = "foreign-input" + elif proof == "record": + refs.record.user_id_key = "foreign" + elif proof == "context": + refs.record.response_context = object() + elif proof == "runtime": + refs.runtime_state = _RuntimeState() + elif proof == "nonce": + params["response_incarnation_id"] = "invalid" + else: + params.pop("response_incarnation_id") + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + foreign_key = lifecycle._input_fence_key(RESPONSE_ID, "foreign", "a" * 32) + others = {key: refs, foreign_key: RuntimeRefs()} + resilience._RUNTIME_REFS.update(others) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + persist = AsyncMock() + reserve = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", persist) + monkeypatch.setattr(host._endpoint._runtime_state, "reserve", reserve) + ctx = _task_context(info) + ctx.input = params + ctx.input_id = identifier + assert await orchestrator._execute_in_task(ctx) is None + persist.assert_not_awaited() + reserve.assert_not_awaited() + assert resilience._RUNTIME_REFS == others + assert registry._slots == {} + + +@pytest.mark.parametrize("entry_mode", ["fresh", "resumed", "recovered"]) +async def test_invalid_user_proven_recovery_defers_without_clearing_refs_while_reserved( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, entry_mode +) -> None: + host = _host() + info = await _durable_task(task_store, host) + original = {**_params(), "response_incarnation_id": "a" * 32} + params = {**original, "user_id_key": []} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, original) + refs.record.resilient_task_run = SimpleNamespace(task_id=info.id, input_id=identifier) + resilience._RUNTIME_REFS[key] = refs + state = host._endpoint._runtime_state + assert await state.reserve(RESPONSE_ID, "owner") + ctx = _task_context(info, entry_mode=entry_mode) + ctx.input = params + ctx.input_id = identifier + try: + result = await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert isinstance(result, _ExitForRecovery) + assert resilience._RUNTIME_REFS == {key: refs} + assert await state.reserve(RESPONSE_ID, "owner") is False + finally: + await state.release_reservation(RESPONSE_ID, "owner") + + +async def test_invalid_user_rechecks_fencing_after_proven_scoped_admission( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch +) -> None: + host = _host() + info = await _durable_task(task_store, host) + original = {**_params(), "response_incarnation_id": "a" * 32} + params = {**original, "user_id_key": []} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + key = lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32) + refs = _cached_refs(host, original) + refs.record.resilient_task_run = SimpleNamespace(task_id=info.id, input_id=identifier) + resilience._RUNTIME_REFS[key] = refs + get = task_store.get + first = True + + async def raced_get(task_id): + nonlocal first + snapshot = await get(task_id) + if first: + first = False + await task_store.update(info.id, TaskPatchRequest(payload={lifecycle._DELETED_INPUT_IDS: [key]})) + return snapshot + + monkeypatch.setattr(task_store, "get", raced_get) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + persist = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", persist) + ctx = _task_context(info) + ctx.input = params + ctx.input_id = identifier + await orchestrator._execute_in_task(ctx) + persist.assert_not_awaited() + assert resilience._RUNTIME_REFS == {} + assert host._endpoint._runtime_state._reservations == set() + + +@pytest.mark.parametrize("user", ["", None]) +async def test_valid_empty_or_legacy_user_partition_preserves_task_execution( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch, user +) -> None: + host = _host() + info = await _durable_task(task_store, host, user=user) + ctx = _task_context(info) + ctx.input = _params(user=user) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + dispatch = AsyncMock(return_value=None) + monkeypatch.setattr(orchestrator, "_execute_admitted_task", dispatch) + await orchestrator._execute_in_task(ctx) + dispatch.assert_awaited_once_with(ctx) + assert host._endpoint._runtime_state._reservations == set() + + +@pytest.mark.parametrize("operation", ["owners", "fence", "legacy-input"]) +async def test_all_task_identity_readers_reject_invalid_persisted_partition( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, operation +) -> None: + host = _host() + info = await _durable_task(task_store, host) + await task_store.update(info.id, TaskPatchRequest(payload={"input": {**_params(), "user_id_key": {}}})) + with pytest.raises(ValueError, match="user_id_key"): + if operation == "owners": + await lifecycle._response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + elif operation == "fence": + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + else: + await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner") + assert lifecycle._DELETED_INPUT_IDS not in (await task_store.get(info.id)).payload + + +@pytest.mark.parametrize("ownership", ["deleted", "replaced", "foreign", "terminal"]) +async def test_malformed_stale_incarnation_never_settles_or_resurrects_response( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, ownership: str +) -> None: + host = _host() + info = await _durable_task(task_store, host, user="other" if ownership == "foreign" else "owner") + params = {**_params(), "response_incarnation_id": "invalid"} + if ownership == "deleted": + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + elif ownership == "terminal": + await task_store.update(info.id, TaskPatchRequest(status="completed")) + else: + await task_store.update( + info.id, + TaskPatchRequest( + payload={ + "input": { + **_params(user="other" if ownership == "foreign" else "owner"), + "response_incarnation_id": "b" * 32, + } + } + ), + ) + restarted = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=False), store=host._endpoint._provider + ) + restarted.response_handler(_noop_handler) + orchestrator = restarted._endpoint._orchestrator._resilient_orchestrator + failed = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", failed) + ctx = _task_context(info) + ctx.input = params + await orchestrator._execute_in_task(ctx) + failed.assert_not_awaited() + assert await restarted._endpoint._runtime_state.list_records() == [] + with pytest.raises(KeyError): + await restarted._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + + +async def test_malformed_current_input_rechecks_deletion_after_scoped_admission( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "invalid"} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + get = task_store.get + first = True + + async def raced_get(task_id: str) -> TaskInfo: + nonlocal first + snapshot = await get(task_id) + if first: + first = False + await task_store.update( + info.id, + TaskPatchRequest(payload={lifecycle._DELETED_INPUT_IDS: [derive_lifecycle_id(RESPONSE_ID, "owner")]}), + ) + return snapshot + + monkeypatch.setattr(task_store, "get", raced_get) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + failed = AsyncMock() + monkeypatch.setattr(orchestrator, "_persist_crash_failed", failed) + ctx = _task_context(info) + ctx.input = params + await orchestrator._execute_in_task(ctx) + failed.assert_not_awaited() + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + with pytest.raises(KeyError): + await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + + +@pytest.mark.parametrize( + "ownership", + ["orphan", "live", "pending", "provider", "durable", "queued", "foreign", "closed", "fenced", "terminal"], +) +async def test_empty_cold_file_replay_requires_proven_absence_of_ownership( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + tmp_path: Path, + ownership: str, +) -> None: + host = _host() + state = host._endpoint._runtime_state + provider = host._endpoint._provider + storage = tmp_path / "streams" + first = _StreamsRegistry() + first.use_file_backed_replay(storage_dir=storage, cursor_fn=lambda event: event["sequence_number"]) + stream_id = derive_lifecycle_id(RESPONSE_ID, "owner") + abandoned = await first.get_or_create(stream_id) + if ownership == "closed": + await abandoned.close() + abandoned._cleanup_locks() # Simulate process loss without closing an ACTIVE log. + registry.use_file_backed_replay(storage_dir=storage, cursor_fn=lambda event: event["sequence_number"]) + if ownership == "live": + await state.add(_execution(status="in_progress")) + elif ownership == "pending": + assert await state.add_pending(_execution(status="in_progress")) + elif ownership == "provider": + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + elif ownership in {"durable", "queued"}: + await _durable_task(task_store, host, attached=True, queued=ownership == "queued") + elif ownership in {"fenced", "terminal"}: + info = await _durable_task(task_store, host) + if ownership == "fenced": + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + else: + await task_store.update(info.id, TaskPatchRequest(status="completed")) + other_id = derive_lifecycle_id(RESPONSE_ID, "other") + other = await registry.get_or_create(other_id) + await other.emit({"sequence_number": 0, "type": "response.created"}) + other_record = _execution(user="other", status="in_progress") + await state.add(other_record) + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="other")) + foreign = await _durable_task(task_store, host, user="other") + before = abandoned._path.read_bytes() + try: + available = await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert available is (ownership in {"orphan", "foreign", "fenced", "terminal"}) + if available: + with pytest.raises(EventStreamNotFoundError): + await registry.get(stream_id) + assert not abandoned._path.exists() + assert not await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + assert await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + else: + assert abandoned._path.read_bytes() == before + assert await state.get(RESPONSE_ID, "other") is other_record + assert await registry.get(other_id) is other + assert (await task_store.get(foreign.id)).payload == foreign.payload + assert (await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="other")))[ + "id" + ] == RESPONSE_ID + finally: + await registry.delete(stream_id) + await registry.delete(other_id) + + +async def test_nonempty_cursorless_file_is_not_mistaken_for_an_empty_orphan( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, tmp_path: Path +) -> None: + registry.use_file_backed_replay(storage_dir=tmp_path / "streams") + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + await replay.emit({"private": "retained"}) + assert await replay.last_cursor() is None + try: + assert not await _host()._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert await registry.get(identifier) is replay + finally: + await registry.delete(identifier) + + +async def test_failed_orphan_file_cleanup_can_be_retried_without_admitting_a_tombstoned_stream( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + unlink = Path.unlink + + def denied(path: Path, *args: Any, **kwargs: Any) -> None: + if path == replay._path: + raise PermissionError("orphan file removal denied") + unlink(path, *args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(Path, "unlink", denied) + with pytest.raises(PermissionError): + await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + with pytest.raises(EventStreamNotFoundError): + await replay.last_cursor() + assert await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert not replay._path.exists() + with pytest.raises(EventStreamNotFoundError): + await registry.get(identifier) + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + finally: + await registry.delete(identifier) + + +async def test_missing_provider_recovery_does_not_leave_a_phantom_runtime_owner( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider +) -> None: + host = _host() + info = await _durable_task(task_store, host) + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(_task_context(info)) + assert await host._endpoint._runtime_state.list_records() == [] + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + + +async def test_orphan_reclamation_requires_available_durable_ownership_lookup( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + + def unavailable() -> Any: + raise TaskManagerNotInitialized("durable ownership unavailable") + + monkeypatch.setattr(lifecycle, "get_task_manager", unavailable) + try: + with pytest.raises(TaskManagerNotInitialized): + await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert await registry.get(identifier) is replay + assert replay._path.read_bytes() == b"" + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + finally: + await registry.delete(identifier) + + +async def test_orphan_probe_forwards_current_platform_call_identity( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + await registry.get_or_create(identifier) + + async def missing(response_id: str, *, context: PlatformContext) -> Any: + assert response_id == RESPONSE_ID + assert context.user_id_key == "owner" + assert context.call_id == "current-call" + raise KeyError(response_id) + + monkeypatch.setattr(host._endpoint._provider, "get_response", missing) + request = _request() + request.scope["headers"].append((b"x-agent-foundry-call-id", b"current-call")) + response = await host._endpoint.handle_create(request) + assert response.status_code == 200 + + async def failed_headers(message: dict[str, Any]) -> None: + raise OSError("headers failed") + + with pytest.raises(OSError, match="headers failed"): + await response.stream_response(failed_headers) + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + + +async def test_crash_empty_replay_is_replaced_by_successful_same_user_execution( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, tmp_path: Path +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + await registry.get_or_create(identifier) + + async def successful(request: Any, context: Any, cancellation_signal: asyncio.Event) -> Any: + async def events() -> Any: + stream = ResponseEventStream(response_id=context.response_id, model="m") + yield stream.emit_created() + yield stream.emit_completed() + + return events() + + host.response_handler(successful) + response = await host._endpoint.handle_create(_request()) + assert response.status_code == 200 + sent = [] + + async def send(message: dict[str, Any]) -> None: + sent.append(message) + + try: + # File-backed replay flushes to disk; allow for contended CI storage. + await asyncio.wait_for(response.stream_response(send), 10) + body = b"".join(message.get("body", b"") for message in sent) + assert b"response.created" in body + assert b"response.completed" in body + assert (await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")))[ + "status" + ] == "completed" + assert (await host._endpoint.handle_create(_request())).status_code == 409 + finally: + await registry.delete(identifier) + + +@pytest.mark.parametrize("failure", ["provider", "tasks", "cleanup"]) +async def test_orphan_probe_failure_is_fail_closed_and_releases_only_its_reservation( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + failure: str, +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + replay = await registry.get_or_create(identifier) + delete = registry.delete + fail = AsyncMock(side_effect=OSError("ownership or cleanup unavailable")) + if failure == "provider": + monkeypatch.setattr(host._endpoint._provider, "get_response", fail) + elif failure == "tasks": + monkeypatch.setattr( + lifecycle, "get_task_manager", lambda: SimpleNamespace(provider=task_store, list_tasks=fail) + ) + else: + monkeypatch.setattr(registry, "delete", fail) + try: + with pytest.raises(OSError, match="ownership or cleanup unavailable"): + await host._endpoint.handle_create(_request()) + assert await registry.get(identifier) is replay + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + finally: + await delete(identifier) + + +async def test_orphan_reclamation_holds_scoped_admission_until_file_cleanup_finishes( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + host = _host() + registry.use_file_backed_replay(storage_dir=tmp_path / "streams", cursor_fn=lambda event: event["sequence_number"]) + identifier = derive_lifecycle_id(RESPONSE_ID, "owner") + await registry.get_or_create(identifier) + reached = asyncio.Event() + release = asyncio.Event() + original = registry.delete + + async def paused_delete(key: str) -> None: + reached.set() + await release.wait() + await original(key) + + monkeypatch.setattr(registry, "delete", paused_delete) + claiming = asyncio.create_task(host._endpoint._reserve_response_id(RESPONSE_ID, "owner")) + try: + await asyncio.wait_for(reached.wait(), 2) + assert not await host._endpoint._reserve_response_id(RESPONSE_ID, "owner") + assert not await host._endpoint._runtime_state.begin_deletion(RESPONSE_ID, "owner") + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "other") + finally: + release.set() + assert await claiming + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "other") + + +@pytest.mark.parametrize("stream,background", [(False, False), (False, True), (True, False), (True, True)]) +@pytest.mark.parametrize("outcome", ["completed", "failed"]) +@pytest.mark.parametrize("file_backed", [False, True]) +async def test_rejected_delete_classification_does_not_interrupt_terminal_publication_or_persistence( + registry, task_store, monkeypatch, tmp_path, stream, background, outcome, file_backed +): + host = _host() + if file_backed: + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=False), + store=FileResponseStore(storage_dir=tmp_path / "responses"), + ) + entered = asyncio.Event() + finish = asyncio.Event() + classified = asyncio.Event() + release_delete = asyncio.Event() + statuses = [] + + async def handler(request, context, cancellation_signal): + events = ResponseEventStream(response_id=context.response_id, model="m") + yield events.emit_created() + yield events.emit_in_progress() + entered.set() + await finish.wait() + if outcome == "failed": + raise RuntimeError("handler failure after creation") + yield events.emit_completed() + + host.response_handler(handler) + endpoint = host._endpoint + state = endpoint._runtime_state + original_delete = endpoint._handle_reserved_delete + + async def paused_classification(*args, **kwargs): + response = await original_delete(*args, **kwargs) + statuses.append(response.status_code) + classified.set() + await release_delete.wait() + return response + + monkeypatch.setattr(endpoint, "_handle_reserved_delete", paused_classification) + foreign = _execution(user="other") + assert await state.add(foreign) + creating = asyncio.create_task(endpoint.handle_create(_request(background=background, stream=stream))) + sending = None + deleting = None + producer = None + messages = [] + + async def send(message): + messages.append(message) + + try: + if stream: + response = await asyncio.wait_for(creating, 2) + assert response.status_code == 200 + sending = asyncio.create_task(response.stream_response(send)) + await asyncio.wait_for(entered.wait(), 2) + original = await state.get(RESPONSE_ID, "owner") + assert original is not None + producer = original.execution_task + assert producer is not None + deleting = asyncio.create_task(endpoint.handle_delete(_delete_request())) + await asyncio.wait_for(classified.wait(), 2) + assert statuses == [400 if background else 404] + assert ("owner", RESPONSE_ID) in state._deletions + assert not await state.is_retained_for_deletion(RESPONSE_ID, "owner") + unrelated = _cached_refs(host, _params()).record + assert unrelated is not None + assert not await state.add(unrelated, expected_record=original) + assert not await state.reserve(RESPONSE_ID, "owner") + finish.set() + await asyncio.wait_for(asyncio.shield(producer), 2) + response = await asyncio.wait_for(creating, 2) + assert response.status_code == 200 + if sending is not None: + await asyncio.wait_for(sending, 2) + body = b"".join(message.get("body", b"") for message in messages) + assert f'"type":"response.{outcome}"'.encode() in body.replace(b" ", b"") + elif not background: + assert json.loads(response.body)["status"] == outcome + stored = await endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert stored["status"] == outcome + current = await state.get(RESPONSE_ID, "owner") + assert current is not None and current.status == outcome + assert current.response_context is original.response_context + assert await state.get(RESPONSE_ID, "other") is foreign + assert not await state.try_evict(RESPONSE_ID, "owner") + get = await endpoint.handle_get(_get_request()) + assert get.status_code == 200 + assert json.loads(get.body)["status"] == outcome + release_delete.set() + assert (await asyncio.wait_for(deleting, 2)).status_code == statuses[0] + assert ("owner", RESPONSE_ID) not in state._deletions + assert not await state.is_deleted(RESPONSE_ID, "owner") + assert state._reservations == set() + assert resilience._RUNTIME_REFS == {} + if file_backed: + restarted_store = FileResponseStore(storage_dir=tmp_path / "responses") + assert (await restarted_store.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")))[ + "status" + ] == outcome + finally: + finish.set() + release_delete.set() + tasks = [task for task in (creating, sending, deleting, producer) if task is not None] + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await registry.delete(derive_lifecycle_id(RESPONSE_ID, "owner")) + + +@pytest.mark.parametrize("outcome", ["completed", "failed"]) +async def test_inline_sync_terminal_replacement_preserves_current_execution_during_delete_classification( + registry, outcome +): + host = _host() + + async def handler(request, context, cancellation_signal): + events = ResponseEventStream(response_id=context.response_id, model="m") + yield events.emit_created() + if outcome == "failed": + raise RuntimeError("handler failure after creation") + yield events.emit_completed() + + host.response_handler(handler) + params = _params() + params["request"] = {**params["request"], "stream": False, "background": False} + refs = _cached_refs(host, params) + original, context = refs.record, refs.context + assert original is not None and context is not None + state = host._endpoint._runtime_state + assert await state.reserve(RESPONSE_ID, "owner", publication_context=context) + assert await state.add(original) + assert await state.begin_deletion(RESPONSE_ID, "owner") + ctx = _ExecutionContext( + response_id=RESPONSE_ID, + agent_reference={}, + model="m", + store=True, + background=False, + stream=False, + input_items=[], + previous_response_id=None, + conversation_id=None, + cancellation_signal=asyncio.Event(), + span=CreateSpan(name="responses.test", tags={"response.id": RESPONSE_ID}), + parsed=CreateResponse(params["request"]), + context=context, + user_id="owner", + ) + response = await host._endpoint._orchestrator._run_sync_inner(ctx, _PipelineState()) + assert response["status"] == outcome + current = await state.get(RESPONSE_ID, "owner") + assert current is not None and current is not original + assert current.response_context is context + assert current.status == outcome + assert (await host._endpoint._provider.get_response(RESPONSE_ID, context=context.platform_context))[ + "status" + ] == outcome + assert not await state.retain_for_deletion(original) + assert await state.get(RESPONSE_ID, "owner") is current + await state.end_deletion(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + + +@pytest.mark.parametrize("runtime_record", [False, True]) +@pytest.mark.parametrize("outcome", ["success", "error", "cancel"]) +async def test_publication_during_provider_delete_preserves_exact_owner_and_retry( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + runtime_record: bool, + outcome: str, +) -> None: + host = _host() + provider = host._endpoint._provider + state = host._endpoint._runtime_state + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + original = _execution() + if runtime_record: + original.response_context = ResponseContext(response_id=RESPONSE_ID, mode_flags=original.mode_flags) + assert await state.reserve(RESPONSE_ID, "owner", publication_context=original.response_context) + assert await state.add(original) + foreign = _execution(user="other") + assert await state.add(foreign) + reached = asyncio.Event() + release = asyncio.Event() + delete = provider.delete_response + + async def paused_delete(*args: Any, **kwargs: Any) -> None: + reached.set() + await release.wait() + if outcome == "error": + raise OSError("provider deletion unavailable") + await delete(*args, **kwargs) + + monkeypatch.setattr(provider, "delete_response", paused_delete) + deleting = asyncio.create_task(host._endpoint.handle_delete(_delete_request())) + try: + await asyncio.wait_for(reached.wait(), 2) + retained = await state.get(RESPONSE_ID, "owner") + assert retained is not None + if runtime_record: + assert retained is original + late = _execution(status="in_progress") + late.response_context = retained.response_context + assert not await state.add(late, expected_record=retained) + assert not await state.add(retained) + assert not await state.add_pending(late) + assert await state.get(RESPONSE_ID, "owner") is retained + assert await state.get(RESPONSE_ID, "other") is foreign + assert not await state.reserve(RESPONSE_ID, "owner") + if outcome == "cancel": + deleting.cancel() + with pytest.raises(asyncio.CancelledError): + await deleting + else: + release.set() + assert (await asyncio.wait_for(deleting, 2)).status_code == (200 if outcome == "success" else 500) + assert not await state.add(late) + if outcome == "success": + assert await state.get(RESPONSE_ID, "owner") is None + assert await state.is_deleted(RESPONSE_ID, "owner") + else: + assert await state.get(RESPONSE_ID, "owner") is retained + assert not await state.is_deleted(RESPONSE_ID, "owner") + assert not await state.add(retained) + assert not await state.add(late, expected_record=retained) + monkeypatch.setattr(provider, "delete_response", delete) + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + assert await state.get(RESPONSE_ID, "other") is foreign + assert not await state.add(late) + finally: + release.set() + if not deleting.done(): + deleting.cancel() + await asyncio.gather(deleting, return_exceptions=True) + await state.release_reservation(RESPONSE_ID, "owner") + + +@pytest.mark.parametrize("retained", [False, True]) +async def test_late_stream_finalization_cannot_publish_over_delete_or_successor( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, retained: bool +) -> None: + host = _host() + refs = _cached_refs(host, _params()) + original = refs.record + context = refs.context + assert original is not None and context is not None + original.set_response_snapshot(_snapshot()) + original.status = "completed" + state = host._endpoint._runtime_state + assert await state.reserve(RESPONSE_ID, "owner", publication_context=context) + assert await state.add(original) + assert await state.begin_deletion(RESPONSE_ID, "owner") + assert await state.retain_for_deletion(original) + pipeline = _PipelineState() + pipeline.bg_record = original + events = ResponseEventStream(response_id=RESPONSE_ID) + pipeline.handler_events = [events.emit_created(), events.emit_completed()] + ctx = _ExecutionContext( + response_id=RESPONSE_ID, + agent_reference={}, + model="m", + store=True, + background=False, + stream=True, + input_items=[], + previous_response_id=None, + conversation_id=None, + cancellation_signal=asyncio.Event(), + span=CreateSpan(name="responses.test", tags={"response.id": RESPONSE_ID}), + parsed=CreateResponse({"model": "m", "input": "hi"}), + context=context, + user_id="owner", + ) + orchestrator = host._endpoint._orchestrator + await orchestrator._finalize_stream(ctx, pipeline) + assert await state.get(RESPONSE_ID, "owner") is original + assert await state.is_retained_for_deletion(RESPONSE_ID, "owner") + assert not await state.is_deleted(RESPONSE_ID, "owner") + if retained: + await state.end_deletion(RESPONSE_ID, "owner") + await orchestrator._finalize_stream(ctx, pipeline) + assert await state.get(RESPONSE_ID, "owner") is original + else: + assert await state.delete(RESPONSE_ID, "owner", expected_record=original) + await state.end_deletion(RESPONSE_ID, "owner") + await orchestrator._finalize_stream(ctx, pipeline) + assert await state.is_deleted(RESPONSE_ID, "owner") + assert await state.get(RESPONSE_ID, "owner") is None + await state.release_reservation(RESPONSE_ID, "owner") + successor = _cached_refs(host, _params()).record + assert successor is not None + assert await state.reserve(RESPONSE_ID, "owner", publication_context=successor.response_context) + assert await state.add(successor) + await orchestrator._finalize_stream(ctx, pipeline) + assert await state.get(RESPONSE_ID, "owner") is successor + assert not await state.is_deleted(RESPONSE_ID, "owner") + + +@pytest.mark.parametrize("runtime_record", [False, True]) +@pytest.mark.parametrize("failure", ["error", "cancel", "cancel_after_write"]) +@pytest.mark.parametrize("restart", [False, True]) +async def test_provider_delete_failure_and_cancellation_retain_retryable_ownership( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + runtime_record: bool, + failure: str, + restart: bool, +) -> None: + host = _host() + provider = host._endpoint._provider + state = host._endpoint._runtime_state + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + record = _execution() + if runtime_record: + await state.add(record) + other = _execution(user="other") + await state.add(other) + replay = await registry.get_or_create(derive_lifecycle_id(RESPONSE_ID, "owner")) + await replay.emit({"sequence_number": 0, "type": "response.completed"}) + await replay.close() + info = await _durable_task(task_store, host) + delete = provider.delete_response + + async def fail_delete(*args: Any, **kwargs: Any) -> None: + if failure == "error": + raise OSError("provider deletion unavailable") + if failure == "cancel_after_write": + await delete(*args, **kwargs) + raise asyncio.CancelledError() + + monkeypatch.setattr(provider, "delete_response", fail_delete) + if failure == "error": + failed = await host._endpoint.handle_delete(_delete_request()) + assert failed.status_code == 500 + else: + with pytest.raises(asyncio.CancelledError): + await host._endpoint.handle_delete(_delete_request()) + retained = await state.get(RESPONSE_ID, "owner") + assert retained is not None + if runtime_record: + assert retained is record + assert not await state.is_deleted(RESPONSE_ID, "owner") + assert not await state.try_evict(RESPONSE_ID, "owner") + assert not await state.reserve(RESPONSE_ID, "owner") + assert await state.get(RESPONSE_ID, "other") is other + assert await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner") + monkeypatch.setattr(provider, "delete_response", delete) + if restart: + restarted = ResponsesAgentServerHost(options=ResponsesServerOptions(resilient_background=False), store=provider) + restarted.response_handler(_noop_handler) + host = restarted + retried = await host._endpoint.handle_delete(_delete_request()) + assert retried.status_code == (404 if restart and failure == "cancel_after_write" else 200) + with pytest.raises(KeyError): + await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") is None + admitted = AsyncMock() + monkeypatch.setattr(host._endpoint._orchestrator._resilient_orchestrator, "_execute_admitted_task", admitted) + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(_task_context(info)) + admitted.assert_not_awaited() + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 404 + + +@pytest.mark.parametrize("disposition", ["re-invoke", "mark-failed"]) +@pytest.mark.parametrize("runtime_record", [False, True]) +async def test_recovery_defers_during_delete_and_cannot_resurrect_after_delete_or_restart( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + disposition: str, + runtime_record: bool, +) -> None: + host = _host() + provider = host._endpoint._provider + await provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + if runtime_record: + await host._endpoint._runtime_state.add(_execution()) + info = await _durable_task(task_store, host, disposition=disposition, multi_turn=True) + ctx = _task_context(info, disposition=disposition) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + admitted = AsyncMock() + monkeypatch.setattr(orchestrator, "_execute_admitted_task", admitted) + reached = asyncio.Event() + release = asyncio.Event() + update = task_store.update + + async def pause_fence(*args: Any, **kwargs: Any) -> TaskInfo: + reached.set() + await release.wait() + return await update(*args, **kwargs) + + monkeypatch.setattr(task_store, "update", pause_fence) + deleting = asyncio.create_task(host._endpoint.handle_delete(_delete_request())) + try: + await asyncio.wait_for(reached.wait(), 2) + assert isinstance(await orchestrator._execute_in_task(ctx), _ExitForRecovery) + admitted.assert_not_awaited() + assert (await host._endpoint.handle_create(_request())).status_code == 409 + release.set() + assert (await deleting).status_code == 200 + finally: + release.set() + await asyncio.gather(deleting, return_exceptions=True) + await orchestrator._execute_in_task(ctx) + admitted.assert_not_awaited() + assert await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") is None + assert await host._endpoint._runtime_state.is_deleted(RESPONSE_ID, "owner") + # Core turn completion patches only its own payload fields. The response + # fence must survive that transition and protect a new process. + await task_store.update(info.id, TaskPatchRequest(payload={"input": None, "retry_attempt": None})) + restarted = ResponsesAgentServerHost(options=ResponsesServerOptions(resilient_background=False), store=provider) + restarted.response_handler(_noop_handler) + recovered = restarted._endpoint._orchestrator._resilient_orchestrator + monkeypatch.setattr(recovered, "_execute_admitted_task", admitted) + await recovered._execute_in_task(ctx) + ctx.input.pop("agent_reference") + await recovered._execute_in_task(ctx) # Malformed input must not synthesize a deleted failed response. + admitted.assert_not_awaited() + assert await restarted._endpoint._runtime_state.list_records() == [] + with pytest.raises(KeyError): + await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + + +async def test_durable_fence_cas_retry_preserves_other_turns_queues_and_users( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + info = await _durable_task(task_store, host, multi_turn=True) + foreign = await _durable_task(task_store, host, user="other", multi_turn=True) + names = host._endpoint._response_task_names() + original = task_store.update + attempts = 0 + next_input = _params(response_id="next-response") + queued_input = _params(response_id="queued-response") + + async def conflict_once(task_id: str, patch: TaskPatchRequest) -> TaskInfo: + nonlocal attempts + attempts += 1 + if attempts == 1: + await original( + task_id, + TaskPatchRequest( + payload={ + "input": next_input, + "last_input_id": derive_lifecycle_id("next-response", "owner"), + "steering": {"pending_inputs": [queued_input]}, + } + ), + ) + raise _HostedConflict(_code="etag_mismatch", status_code=412) + return await original(task_id, patch) + + monkeypatch.setattr(task_store, "update", conflict_once) + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", names) + assert attempts == 2 + updated = await task_store.get(info.id) + assert updated.payload["input"] == next_input + assert updated.payload["steering"]["pending_inputs"] == [queued_input] + assert updated.payload["last_input_id"] == derive_lifecycle_id("next-response", "owner") + assert await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner") + assert not await lifecycle._task_input_deleted(info.id, "next-response", "owner") + assert not await lifecycle._task_input_deleted(foreign.id, RESPONSE_ID, "other") + assert (await task_store.get(foreign.id)).to_dict() == foreign.to_dict() + + +@pytest.mark.parametrize("entry_mode", ["fresh", "resumed", "queued", "recovered"]) +async def test_deleted_durable_input_is_never_dispatched( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, entry_mode: str +) -> None: + host = _host() + info = await _durable_task(task_store, host) + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + admitted = AsyncMock() + monkeypatch.setattr(orchestrator, "_execute_admitted_task", admitted) + await orchestrator._execute_in_task(_task_context(info, entry_mode=entry_mode)) + admitted.assert_not_awaited() + + +@pytest.mark.parametrize("stale_record", [False, True]) +async def test_normal_recovery_owns_reservation_through_handler_and_releases_it_on_failure( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + stale_record: bool, +) -> None: + host = _host() + state = host._endpoint._runtime_state + info = await _durable_task(task_store, host) + if stale_record: + record = _execution(status="in_progress") + record.execution_task = asyncio.create_task(asyncio.sleep(0)) + await record.execution_task + await state.add(record) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + + async def execute(ctx: Any) -> None: + assert not await state.reserve(RESPONSE_ID, "owner") + if not stale_record: + assert not await state.begin_deletion(RESPONSE_ID, "owner") + assert await state.reserve(RESPONSE_ID, "other") + await state.release_reservation(RESPONSE_ID, "other") + raise RuntimeError("recoverable handler failure") + + monkeypatch.setattr(orchestrator, "_execute_admitted_task", execute) + with pytest.raises(RuntimeError, match="recoverable handler failure"): + await orchestrator._execute_in_task(_task_context(info)) + assert await state.reserve(RESPONSE_ID, "owner", recovery=True) + await state.release_reservation(RESPONSE_ID, "owner") + + +async def test_runtime_completed_deletion_fences_recovery_but_allows_explicit_fresh_reuse() -> None: + state = _RuntimeState() + await state.mark_deleted(RESPONSE_ID, "owner") + assert not await state.reserve(RESPONSE_ID, "owner", recovery=True) + assert await state.reserve(RESPONSE_ID, "other", recovery=True) + await state.release_reservation(RESPONSE_ID, "other") + assert await state.reserve(RESPONSE_ID, "owner") + assert not await state.reserve(RESPONSE_ID, "owner", recovery=True) + await state.release_reservation(RESPONSE_ID, "owner") + + +@pytest.mark.parametrize("steerable", [False, True]) +async def test_delete_then_same_id_post_resumes_same_conversation_with_a_new_incarnation( + registry: _StreamsRegistry, + task_store: LocalFileTaskProvider, + monkeypatch: pytest.MonkeyPatch, + steerable: bool, + tmp_path: Path, +) -> None: + from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as resilience + + provider = FileResponseStore(storage_dir=tmp_path / "responses") + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True, steerable_conversations=steerable), store=provider + ) + executions = [] + + async def handler(request, context, cancellation_signal): + executions.append((context.response_id, request["input"], context.is_recovery)) + + async def events(): + stream = ResponseEventStream(response_id=context.response_id, model="m") + yield stream.emit_created() + for event in stream.output_item_message(request["input"]): + yield event + yield stream.emit_completed() + + return events() + + host.response_handler(handler) + manager = TaskManager(host.config, provider=task_store) + host._task_manager = manager + set_task_manager(manager) + monkeypatch.setattr(lifecycle, "get_task_manager", lambda: manager) + suspended = asyncio.Queue() + original_update = task_store.update + + async def observed_update(task_id, patch): + info = await original_update(task_id, patch) + if patch.status == "suspended": + suspended.put_nowait(info) + return info + + monkeypatch.setattr(task_store, "update", observed_update) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + original_execute = orchestrator._execute_admitted_task + boundaries = [] + second_entered = asyncio.Event() + release_second = asyncio.Event() + + async def observed_execute(ctx): + boundaries.append(dict(ctx.input)) + if len(boundaries) == 2: + second_entered.set() + await release_second.wait() + return await original_execute(ctx) + + monkeypatch.setattr(orchestrator, "_execute_admitted_task", observed_execute) + client = _AsyncAsgiClient(host) + headers = {"x-agent-user-id": "owner"} + payload = { + "response_id": RESPONSE_ID, + "conversation": "conv_incarnation_reuse", + "model": "m", + "input": "first incarnation", + "store": True, + "background": True, + "stream": True, + } + second_post = None + try: + first = await client.post("/responses", json_body=payload, headers=headers) + assert first.status_code == 200, first.body + first_info = await asyncio.wait_for(suspended.get(), 3) + first_boundary = boundaries[0] + assert first_boundary["response_incarnation_id"] + deleted = await client.request("DELETE", f"/responses/{RESPONSE_ID}", headers=headers) + assert deleted.status_code == 200, deleted.body + assert (await task_store.get(first_info.id)).status == "suspended" + + second_post = asyncio.create_task( + client.post("/responses", json_body={**payload, "input": "second incarnation"}, headers=headers) + ) + await asyncio.wait_for(second_entered.wait(), 3) + current = await task_store.get(first_info.id) + second_boundary = boundaries[1] + assert current.payload["input"] == second_boundary + assert current.payload["last_input_id"] == derive_lifecycle_id(RESPONSE_ID, "owner") + assert first_boundary["response_incarnation_id"] != second_boundary["response_incarnation_id"] + refs_key = lifecycle._input_fence_key(RESPONSE_ID, "owner", second_boundary["response_incarnation_id"]) + refs = resilience._RUNTIME_REFS[refs_key] + old_context = _task_context(first_info) + old_context.input = first_boundary + await orchestrator._execute_in_task(old_context) + assert executions == [(RESPONSE_ID, "first incarnation", False)] + assert resilience._RUNTIME_REFS[refs_key] is refs + assert not await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + + # A recovery of the admitted new incarnation must defer, not settle + # against the previous incarnation's in-memory deletion tombstone. + new_context = _task_context(current) + new_context.input = second_boundary + assert isinstance(await orchestrator._execute_in_task(new_context), _ExitForRecovery) + assert resilience._RUNTIME_REFS[refs_key] is refs + release_second.set() + second = await asyncio.wait_for(second_post, 3) + assert second.status_code == 200, second.body + second_info = await asyncio.wait_for(suspended.get(), 3) + assert second_info.id == first_info.id + snapshot = await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert snapshot["status"] == "completed" + assert "response_incarnation_id" not in snapshot + assert b"second incarnation" in second.body + assert executions == [(RESPONSE_ID, "first incarnation", False), (RESPONSE_ID, "second incarnation", False)] + + # A fresh process reading the retained task must still reject the + # old incarnation, without modifying the replacement response. + restarted = ResponsesAgentServerHost(options=ResponsesServerOptions(resilient_background=False), store=provider) + restarted.response_handler(_noop_handler) + recovered = restarted._endpoint._orchestrator._resilient_orchestrator + unexpected = AsyncMock() + monkeypatch.setattr(recovered, "_execute_admitted_task", unexpected) + await recovered._execute_in_task(old_context) + unexpected.assert_not_awaited() + assert await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) == snapshot + + from azure.ai.agentserver.responses._id_generator import IdGenerator + + other_response_id = IdGenerator.new_response_id() + third = await client.post( + "/responses", + json_body={**payload, "response_id": other_response_id, "input": "another turn"}, + headers=headers, + ) + assert third.status_code == 200, third.body + third_info = await asyncio.wait_for(suspended.get(), 3) + assert third_info.id == first_info.id + assert executions[-1] == (other_response_id, "another turn", False) + assert (await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")))[ + "status" + ] == "completed" + finally: + release_second.set() + if second_post is not None: + await asyncio.gather(second_post, return_exceptions=True) + await manager.shutdown() + + +@pytest.mark.parametrize("attached", [False, True]) +async def test_nonce_fence_preserves_old_incarnation_across_cas_retry_and_fresh_resume( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch, attached: bool +) -> None: + host = _host() + info = await _durable_task(task_store, host, multi_turn=True) + old = {**_params(), "response_incarnation_id": "a" * 32} + new = {**_params(), "response_incarnation_id": "b" * 32} + _, slot = _resolve_input_storage( + old, threshold_bytes=0 if attached else 1000000, key_for_attachment="input", task_id=info.id + ) + await task_store.update( + info.id, TaskPatchRequest(payload={"input": slot}, attachments={"input": old} if attached else None) + ) + original_update = task_store.update + count = 0 + + async def raced_update(task_id, patch): + nonlocal count + count += 1 + if count == 1: + await original_update(task_id, TaskPatchRequest(payload={"input": new})) + raise _HostedConflict(_code="etag_mismatch", status_code=412) + return await original_update(task_id, patch) + + monkeypatch.setattr(task_store, "update", raced_update) + await lifecycle._fence_response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + assert count == 2 + assert await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner", incarnation_id="a" * 32) + assert not await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner", incarnation_id="b" * 32) + assert await lifecycle._task_input_deleted(info.id, RESPONSE_ID, "owner") + assert await lifecycle._response_tasks(RESPONSE_ID, "owner", host._endpoint._response_task_names()) + + +async def test_recovery_rechecks_incarnation_after_acquiring_reservation_when_delete_races( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + await host._endpoint._provider.create_response(_snapshot(), [], None, context=PlatformContext(user_id_key="owner")) + info = await _durable_task(task_store, host) + params = {**_params(), "response_incarnation_id": "a" * 32} + await task_store.update(info.id, TaskPatchRequest(payload={"input": params})) + get = task_store.get + first = True + + async def raced_get(task_id): + nonlocal first + snapshot = await get(task_id) + if first: + first = False + assert (await host._endpoint.handle_delete(_delete_request())).status_code == 200 + return snapshot + + monkeypatch.setattr(task_store, "get", raced_get) + orchestrator = host._endpoint._orchestrator._resilient_orchestrator + unexpected = AsyncMock() + monkeypatch.setattr(orchestrator, "_execute_admitted_task", unexpected) + ctx = _task_context(info) + ctx.input = params + await orchestrator._execute_in_task(ctx) + unexpected.assert_not_awaited() + assert await host._endpoint._runtime_state.list_records() == [] + assert await host._endpoint._runtime_state.reserve(RESPONSE_ID, "owner") + await host._endpoint._runtime_state.release_reservation(RESPONSE_ID, "owner") + with pytest.raises(KeyError): + await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + + +async def test_current_incarnation_recovery_survives_a_previous_incarnation_tombstone( + registry: _StreamsRegistry, task_store: LocalFileTaskProvider +) -> None: + host = _host() + calls = [] + + async def handler(request, context, cancellation_signal): + assert context.is_recovery + calls.append(context.response_id) + + async def events(): + stream = ResponseEventStream(response_id=context.response_id, model="m") + yield stream.emit_created() + yield stream.emit_completed() + + return events() + + host.response_handler(handler) + params = {**_params(), "response_incarnation_id": "b" * 32} + info = await _durable_task(task_store, host, multi_turn=True) + await task_store.update( + info.id, + TaskPatchRequest( + payload={ + "input": params, + "responses_deleted_input_ids": [ + derive_lifecycle_id(RESPONSE_ID, "owner"), + lifecycle._input_fence_key(RESPONSE_ID, "owner", "a" * 32), + ], + } + ), + ) + await host._endpoint._runtime_state.mark_deleted(RESPONSE_ID, "owner") + await host._endpoint._provider.create_response( + ResponseObject({"id": RESPONSE_ID, "status": "in_progress", "model": "m", "background": True, "output": []}), + [], + None, + context=PlatformContext(user_id_key="owner"), + ) + ctx = _task_context(info) + ctx.input = params + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert calls == [RESPONSE_ID] + assert not await host._endpoint._runtime_state.is_deleted(RESPONSE_ID, "owner") + assert (await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")))[ + "status" + ] == "completed" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_runtime_state.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_runtime_state.py index 454306304f79..72eae01ecdaf 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_runtime_state.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_runtime_state.py @@ -4,11 +4,13 @@ from __future__ import annotations +import asyncio from typing import cast import pytest from azure.ai.agentserver.responses.hosting._runtime_state import _RuntimeState +from azure.ai.agentserver.responses import ResponseContext from azure.ai.agentserver.responses.models import ResponseObject from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags @@ -26,6 +28,7 @@ def _make_execution( status: str = "queued", input_items: list[dict] | None = None, previous_response_id: str | None = None, + user_id_key: str | None = None, ) -> ResponseExecution: return ResponseExecution( response_id=response_id, @@ -33,6 +36,7 @@ def _make_execution( status=status, # type: ignore[arg-type] input_items=input_items, previous_response_id=previous_response_id, + user_id_key=user_id_key, ) @@ -61,6 +65,270 @@ async def test_get_nonexistent_returns_none() -> None: assert await state.get("unknown_id") is None +@pytest.mark.asyncio +async def test_same_id_records_are_partitioned_by_user() -> None: + state = _RuntimeState() + response_id = "caresp_shared000000000000000000000000" + user_a = _make_execution(response_id, user_id_key="user-A") + user_b = _make_execution(response_id, user_id_key="user-B") + + await state.add(user_a) + await state.add(user_b) + + assert await state.get(response_id, "user-A") is user_a + assert await state.get(response_id, "user-B") is user_b + assert await state.get(response_id) is None + + +@pytest.mark.asyncio +async def test_reserve_rejects_duplicate_live_id_only_within_user() -> None: + state = _RuntimeState() + response_id = "caresp_reserved0000000000000000000000" + + assert await state.reserve(response_id, "user-A") is True + assert await state.reserve(response_id, "user-A") is False + assert await state.reserve(response_id, "user-B") is True + + await state.release_reservation(response_id, "user-A") + assert await state.reserve(response_id, "user-A") is True + assert await state.reserve(response_id, "user-B") is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("guard", ["none", "reserved", "deleting", "retained", "draining", "deleted"]) +async def test_recovered_admission_retires_only_exact_stale_record_after_all_guards(guard: str) -> None: + state = _RuntimeState() + assert await state.reserve("shared", "owner") + stale = _make_execution("shared", user_id_key="owner", status="in_progress") + stale.execution_task = asyncio.create_task(asyncio.sleep(0)) + await stale.execution_task + await state.add(stale) + other = _make_execution("shared", user_id_key="other") + await state.add(other) + if guard != "reserved": + await state.release_reservation("shared", "owner") + if guard in {"deleting", "retained"}: + assert await state.begin_deletion("shared", "owner") + if guard == "retained": + assert await state.retain_for_deletion(stale) + await state.end_deletion("shared", "owner") + elif guard == "draining": + await state.begin_draining() + elif guard == "deleted": + await state.mark_deleted("shared", "owner") + assert await state.reserve("shared", "owner", recovery=True) is (guard == "none") + assert await state.get("shared", "owner") is (None if guard == "none" else stale) + assert await state.get("shared", "other") is other + if guard == "none": + assert not await state.begin_deletion("shared", "owner") + replacement = _make_execution("shared", user_id_key="owner", status="completed") + await state.add(replacement) + await state.release_reservation("shared", "owner") + assert await state.get("shared", "owner") is replacement + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["in_progress", "completed"]) +async def test_recovered_admission_never_removes_a_newer_live_or_terminal_record(status: str) -> None: + state = _RuntimeState() + stale = _make_execution("shared", user_id_key="owner", status="in_progress") + stale.execution_task = asyncio.create_task(asyncio.sleep(0)) + await stale.execution_task + await state.add(stale) + current = _make_execution("shared", user_id_key="owner", status=status) + assert await state.add(current, expected_record=stale) + assert not await state.reserve("shared", "owner", recovery=True, incarnation_id="b" * 32) + assert await state.get("shared", "owner") is current + + +@pytest.mark.asyncio +async def test_deletion_reservation_blocks_eviction_and_admission_until_cleanup(): + state = _RuntimeState() + record = _make_execution("shared", user_id_key="owner", status="completed") + assert await state.reserve("shared", "owner") + await state.add(record) + assert await state.begin_deletion("shared", "owner") + assert not await state.try_evict("shared", "owner") + await state.release_reservation("shared", "owner") + assert not await state.reserve("shared", "owner") + assert not await state.begin_deletion("shared", "owner") + assert await state.reserve("shared", "other") + await state.end_deletion("shared", "owner") + assert await state.try_evict("shared", "owner") + assert await state.reserve("shared", "owner") + + +@pytest.mark.asyncio +async def test_deletion_cannot_enter_during_unpublished_create(): + state = _RuntimeState() + assert await state.reserve("shared", "owner") + assert not await state.begin_deletion("shared", "owner") + await state.release_reservation("shared", "owner") + assert await state.begin_deletion("shared", "owner") + await state.end_deletion("shared", "owner") + + +@pytest.mark.asyncio +async def test_compare_delete_does_not_remove_a_replacement_record(): + state = _RuntimeState() + old = _make_execution("shared", user_id_key="owner", status="completed") + replacement = _make_execution("shared", user_id_key="owner") + await state.add(old) + assert await state.add(replacement, expected_record=old) + assert not await state.delete("shared", "owner", expected_record=old) + assert await state.get("shared", "owner") is replacement + assert not await state.is_deleted("shared", "owner") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("retained", [False, True]) +@pytest.mark.parametrize("marked_deleted", [False, True]) +async def test_publication_cannot_replace_scoped_deletion_ownership_or_clear_its_marker( + retained: bool, marked_deleted: bool +) -> None: + state = _RuntimeState() + owner = _make_execution("shared", user_id_key="owner", status="completed") + foreign = _make_execution("shared", user_id_key="other", status="completed") + assert await state.reserve("shared", "owner") + assert await state.add(owner) + assert await state.add(foreign) + assert await state.begin_deletion("shared", "owner") + if retained: + assert await state.retain_for_deletion(owner) + await state.end_deletion("shared", "owner") + if marked_deleted: + await state.mark_deleted("shared", "owner") + replacement = _make_execution("shared", user_id_key="owner", status="in_progress") + assert not await state.add(replacement, expected_record=owner) + assert await state.add(owner) is (not retained and not marked_deleted) + assert not await state.add_pending(replacement) + assert await state.get("shared", "owner") is owner + assert await state.get("shared", "other") is foreign + assert await state.is_deleted("shared", "owner") is marked_deleted + assert not await state.try_evict("shared", "owner") + + +@pytest.mark.parametrize("phase", ["classification", "cleanup", "cleanup-retry"]) +@pytest.mark.parametrize( + "publication", + ["exact", "same-context", "foreign-context", "contextless-replacement", "contextless-exact", "wrong-predecessor"], +) +async def test_delete_classification_allows_only_current_execution_until_cleanup_retains_ownership(phase, publication): + state = _RuntimeState() + owner = _make_execution("shared", user_id_key="owner", status="in_progress") + if publication != "contextless-exact": + owner.response_context = ResponseContext(response_id="shared", mode_flags=owner.mode_flags) + assert await state.reserve("shared", "owner", publication_context=owner.response_context) + assert await state.add(owner) + foreign = _make_execution("shared", user_id_key="other", status="completed") + assert await state.add(foreign) + assert await state.begin_deletion("shared", "owner") + if phase != "classification": + assert await state.retain_for_deletion(owner) + if phase == "cleanup-retry": + await state.end_deletion("shared", "owner") + candidate = owner + if publication not in ("exact", "contextless-exact"): + candidate = _make_execution("shared", user_id_key="owner", status="completed") + if publication in ("same-context", "wrong-predecessor"): + candidate.response_context = owner.response_context + elif publication == "foreign-context": + candidate.response_context = ResponseContext(response_id="shared", mode_flags=candidate.mode_flags) + predecessor = _make_execution("shared", user_id_key="owner") if publication == "wrong-predecessor" else owner + allowed = phase == "classification" and publication in ("exact", "same-context", "contextless-exact") + assert await state.add(candidate, expected_record=predecessor) is allowed + current = candidate if allowed else owner + assert await state.get("shared", "owner") is current + assert await state.get("shared", "other") is foreign + assert not await state.reserve("shared", "owner") + assert not await state.add_pending(_make_execution("shared", user_id_key="owner")) + assert not await state.try_evict("shared", "owner") + if allowed: + if candidate is not owner: + assert not await state.retain_for_deletion(owner) + assert await state.get("shared", "owner") is candidate + assert await state.retain_for_deletion(current) + assert not await state.add(current) + assert await state.get("shared", "owner") is current + + +async def test_delete_classification_without_published_owner_cannot_admit_execution(): + state = _RuntimeState() + assert await state.begin_deletion("shared", "owner") + candidate = _make_execution("shared", user_id_key="owner", status="completed") + candidate.response_context = ResponseContext(response_id="shared", mode_flags=candidate.mode_flags) + assert not await state.add(candidate) + assert not await state.add_pending(candidate) + assert not await state.reserve("shared", "owner") + assert await state.get("shared", "owner") is None + + +@pytest.mark.asyncio +async def test_completed_delete_revokes_old_publication_and_fresh_admission_binds_exact_context() -> None: + state = _RuntimeState() + old = _make_execution("shared", user_id_key="owner", status="completed") + old.response_context = ResponseContext(response_id="shared", mode_flags=old.mode_flags) + assert await state.reserve("shared", "owner", publication_context=old.response_context) + assert await state.add(old) + assert await state.begin_deletion("shared", "owner") + assert await state.retain_for_deletion(old) + assert await state.delete("shared", "owner", expected_record=old) + await state.end_deletion("shared", "owner") + assert not await state.add(old) + assert not await state.reserve("shared", "owner") + assert await state.is_deleted("shared", "owner") + await state.release_reservation("shared", "owner") + fresh = _make_execution("shared", user_id_key="owner", status="completed") + fresh.response_context = ResponseContext(response_id="shared", mode_flags=fresh.mode_flags) + assert await state.reserve("shared", "owner", publication_context=fresh.response_context) + assert not await state.add(old) + assert await state.is_deleted("shared", "owner") + assert await state.add_pending(fresh) + assert not await state.add(old) + assert state._pending_records[("owner", "shared")] is fresh + assert await state.add(fresh) + assert not await state.is_deleted("shared", "owner") + assert not await state.add(old, expected_record=fresh) + assert not await state.try_evict("shared", "owner", expected_record=old) + assert await state.get("shared", "owner") is fresh + await state.release_reservation("shared", "owner") + assert state._publication_contexts == {} + assert not await state.add(old) + assert await state.try_evict("shared", "owner", expected_record=fresh) + + +@pytest.mark.asyncio +async def test_publication_replaces_only_an_exact_contextless_predecessor() -> None: + state = _RuntimeState() + old = _make_execution("shared", status="completed") + replacement = _make_execution("shared", status="completed") + assert await state.add(old) + assert not await state.add(replacement) + assert await state.add(replacement, expected_record=old) + assert not await state.add(old, expected_record=old) + assert await state.get("shared") is replacement + + +def test_anonymous_user_isolation_is_not_a_wildcard() -> None: + assert _RuntimeState.check_user_isolation(None, None) is True + assert _RuntimeState.check_user_isolation(None, "user-A") is False + + +@pytest.mark.asyncio +async def test_reservation_survives_publication_and_eviction_until_request_cleanup(): + state = _RuntimeState() + record = _make_execution("shared", user_id_key="user-A", status="completed") + assert await state.reserve("shared", "user-A") + assert await state.add_pending(record) + await state.add(record) + assert await state.try_evict("shared", "user-A") + assert not await state.reserve("shared", "user-A") + assert await state.reserve("shared", "user-B") + await state.release_reservation("shared", "user-A") + assert await state.reserve("shared", "user-A") + assert not await state.reserve("shared", "user-B") + + # --------------------------------------------------------------------------- # T3 – delete marks deleted; get returns None; is_deleted returns True # --------------------------------------------------------------------------- @@ -262,6 +530,95 @@ async def test_begin_draining_rejects_new_pending_work() -> None: assert await state.list_records() == [accepted] +@pytest.mark.asyncio +@pytest.mark.parametrize("recovery", [False, True]) +@pytest.mark.parametrize("user_id_key", [None, "", "owner", "other"]) +async def test_draining_rejects_every_new_reservation_without_mutating_ownership(recovery, user_id_key): + state = _RuntimeState() + assert await state.reserve("existing", "owner") + before = dict(state._publication_contexts) + await state.begin_draining() + assert not await state.reserve("new", user_id_key, recovery=recovery, incarnation_id="a" * 32) + assert state._reservations == {("owner", "existing")} + assert state._publication_contexts == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize("admission", ["none", "reserved", "pending", "published"]) +@pytest.mark.parametrize("publication", ["exact", "same-context", "foreign-context", "wrong-predecessor"]) +async def test_draining_publication_requires_an_exact_snapshot_execution(admission, publication): + state = _RuntimeState() + owner = _make_execution("shared", user_id_key="owner", status="in_progress") + owner.response_context = ResponseContext(response_id="shared", mode_flags=owner.mode_flags) + foreign = _make_execution("shared", user_id_key="other", status="completed") + assert await state.add(foreign) + if admission != "none": + assert await state.reserve("shared", "owner", publication_context=owner.response_context) + if admission == "pending": + assert await state.add_pending(owner) + elif admission == "published": + assert await state.add(owner) + snapshot = await state.begin_draining() + assert (owner in snapshot) is (admission in ("pending", "published")) + candidate = owner if publication == "exact" else _make_execution("shared", user_id_key="owner", status="completed") + if publication != "exact": + candidate.response_context = ( + ResponseContext(response_id="shared", mode_flags=candidate.mode_flags) + if publication == "foreign-context" + else owner.response_context + ) + predecessor = _make_execution("shared", user_id_key="owner") if publication == "wrong-predecessor" else None + allowed = admission in ("pending", "published") and publication in ("exact", "same-context") + assert await state.add(candidate, expected_record=predecessor) is allowed + assert await state.get("shared", "owner") is (candidate if allowed else owner if admission == "published" else None) + assert await state.get("shared", "other") is foreign + if admission == "pending" and not allowed: + assert state._pending_records[("owner", "shared")] is owner + assert not await state.add_pending(candidate) + assert not await state.is_deleted("shared", "owner") + + +@pytest.mark.asyncio +async def test_draining_contextless_replacement_cannot_use_expected_record_as_a_new_execution(): + state = _RuntimeState() + owner = _make_execution("shared", status="in_progress") + assert await state.add(owner) + await state.begin_draining() + replacement = _make_execution("shared", status="completed") + assert not await state.add(replacement, expected_record=owner) + assert await state.add(owner) + assert await state.get("shared") is owner + + +@pytest.mark.asyncio +async def test_draining_finalization_preserves_newer_record_against_stale_predecessor(): + state = _RuntimeState() + owner = _make_execution("shared", user_id_key="owner", status="in_progress") + owner.response_context = ResponseContext(response_id="shared", mode_flags=owner.mode_flags) + assert await state.add(owner) + await state.begin_draining() + terminal = _make_execution("shared", user_id_key="owner", status="completed") + terminal.response_context = owner.response_context + assert await state.add(terminal, expected_record=owner) + assert not await state.add(owner, expected_record=owner) + assert await state.get("shared", "owner") is terminal + + +@pytest.mark.asyncio +async def test_exact_pending_cleanup_preserves_successor_and_foreign_user(): + state = _RuntimeState() + old = _make_execution("shared", user_id_key="owner") + current = _make_execution("shared", user_id_key="owner") + foreign = _make_execution("shared", user_id_key="other") + assert await state.add_pending(old) + await state.discard_pending("shared", "owner", expected_record=old) + assert await state.add_pending(current) + assert await state.add_pending(foreign) + await state.begin_draining() + await state.discard_pending("shared", "owner", expected_record=old) + assert state._pending_records == {("owner", "shared"): current, ("other", "shared"): foreign} + + # --------------------------------------------------------------------------- # T1 (Task 7.1) – _ExecutionRecord is no longer exported from _runtime_state # --------------------------------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_shutdown_recovery_handoff.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_shutdown_recovery_handoff.py new file mode 100644 index 000000000000..ceb5e8acb34c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_shutdown_recovery_handoff.py @@ -0,0 +1,211 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Real task-context signal handoffs across durable response reconstruction.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from azure.ai.agentserver.core.tasks._context import TaskContext, _ExitForRecovery +from azure.ai.agentserver.core.tasks._local_provider import LocalFileTaskProvider +from azure.ai.agentserver.core.tasks._models import TaskPatchRequest +from azure.ai.agentserver.responses import PlatformContext, ResponsesAgentServerHost, ResponsesServerOptions +from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as resilience +from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id +from azure.ai.agentserver.responses.store._file import FileResponseStore +from azure.ai.agentserver.responses.streaming import ResponseEventStream +from tests.unit.test_response_delete_recovery import _durable_task, _params, _snapshot, task_store +from tests.unit.test_stream_lifecycle_cleanup import RESPONSE_ID, _request, registry + + +def _host(tmp_path: Path, handler: Any) -> ResponsesAgentServerHost: + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=True, shutdown_grace_period_seconds=1), + store=FileResponseStore(tmp_path / "responses"), + ) + host.response_handler(handler) + return host + + +async def _stored_context( + host: ResponsesAgentServerHost, provider: LocalFileTaskProvider, *, stream: bool +) -> TaskContext[dict[str, Any]]: + info = await _durable_task(provider, host) + params = _params() + params["request"]["stream"] = stream + params["response_incarnation_id"] = "a" * 32 + await provider.update(info.id, TaskPatchRequest(payload={"input": params})) + snapshot = _snapshot() + snapshot["status"] = "in_progress" + await host._endpoint._provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + return TaskContext( + task_id=info.id, + input_id=derive_lifecycle_id(RESPONSE_ID, "owner"), + session_id="local", + input=json.loads(json.dumps(params)), + entry_mode="recovered", + ) + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("explicit", [False, True]) +async def test_response_shutdown_defers_real_task_before_manager_shutdown_and_recovers( + registry, task_store, tmp_path, stream, explicit +): + entered = asyncio.Event() + contexts = [] + + async def interrupted(request, context, cancellation_signal): + contexts.append(context) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_in_progress() + yield events.checkpoint() + entered.set() + await cancellation_signal.wait() + assert context.shutdown.is_set() + if explicit: + await context.exit_for_recovery() + + host = _host(tmp_path, interrupted) + ctx = await _stored_context(host, task_store, stream=stream) + executing = asyncio.create_task(host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx)) + try: + await asyncio.wait_for(entered.wait(), 2) + record = await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") + assert record is not None + assert record.execution_task is executing + assert not ctx.shutdown.is_set() + await asyncio.wait_for(host._endpoint.handle_shutdown(), 2) + assert ctx.shutdown.is_set() + assert isinstance(await executing, _ExitForRecovery) + stored = await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert stored["status"] == "in_progress" + assert host._endpoint._runtime_state._reservations == set() + assert resilience._RUNTIME_REFS == {} + + seen = [] + + async def resumed(request, context, cancellation_signal): + seen.append((context.is_recovery, context.persisted_response["status"], context.shutdown.is_set())) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_in_progress() + yield events.emit_completed() + + restarted = _host(tmp_path, resumed) + persisted_task = await task_store.get(ctx.task_id) + assert persisted_task is not None + recovered = TaskContext( + task_id=ctx.task_id, + input_id=ctx.input_id, + session_id="local", + input=json.loads(json.dumps(persisted_task.payload["input"])), + entry_mode="recovered", + ) + await restarted._endpoint._orchestrator._resilient_orchestrator._execute_in_task(recovered) + assert seen == [(True, "in_progress", False)] + terminal = await restarted._endpoint._provider.get_response( + RESPONSE_ID, context=PlatformContext(user_id_key="owner") + ) + assert terminal["status"] == "completed" + assert restarted._endpoint._runtime_state._reservations == set() + assert resilience._RUNTIME_REFS == {} + finally: + for context in contexts: + context._cancellation_signal.set() + if not executing.done(): + executing.cancel() + await asyncio.gather(executing, return_exceptions=True) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_cold_recovery_cancel_wakes_tracked_execution_and_persists_cancelled( + registry, task_store, tmp_path, stream +): + entered = asyncio.Event() + signals = [] + contexts = [] + + async def handler(request, context, cancellation_signal): + signals.append(cancellation_signal) + contexts.append(context) + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_in_progress() + entered.set() + await cancellation_signal.wait() + assert context.client_cancelled + assert not context.shutdown.is_set() + + host = _host(tmp_path, handler) + ctx = await _stored_context(host, task_store, stream=stream) + executing = asyncio.create_task(host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx)) + try: + await asyncio.wait_for(entered.wait(), 2) + record = await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") + assert record is not None + assert record.execution_task is executing + assert record.cancel_signal is signals[0] + assert contexts[0]._cancellation_signal is signals[0] + request = _request(stream=stream) + request.scope["path_params"] = {"response_id": RESPONSE_ID} + cancelled = await asyncio.wait_for(host._endpoint.handle_cancel(request), 2) + assert cancelled.status_code == 200 + assert json.loads(cancelled.body)["status"] == "cancelled" + await asyncio.wait_for(executing, 2) + stored = await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert stored["status"] == "cancelled" + assert not ctx.shutdown.is_set() + assert host._endpoint._runtime_state._reservations == set() + assert resilience._RUNTIME_REFS == {} + finally: + for signal in signals: + signal.set() + if not executing.done(): + executing.cancel() + await asyncio.gather(executing, return_exceptions=True) + + +async def test_task_binding_preserves_shutdown_signalled_before_task_context_attachment(registry, tmp_path): + async def handler(request, context, cancellation_signal): + if False: + yield + + host = _host(tmp_path, handler) + record, context = resilience._reconstruct_from_params( + params=_params(), + response_id=RESPONSE_ID, + provider=host._endpoint._provider, + runtime_state=host._endpoint._runtime_state, + runtime_options=host._endpoint._runtime_options, + ) + context.shutdown.set() + ctx = TaskContext(task_id="binding", session_id="local", input=_params()) + assert record.response_context is context + assert not ctx.shutdown.is_set() + assert not await host._endpoint._orchestrator._resilient_orchestrator._flatten_recovery_context(ctx, context, False) + assert ctx.shutdown.is_set() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_recovery_exit_outside_shutdown_keeps_core_misuse_guard(registry, task_store, tmp_path, stream): + async def handler(request, context, cancellation_signal): + events = ResponseEventStream(response_id=context.response_id, request=request) + yield events.emit_created() + yield events.emit_in_progress() + await context.exit_for_recovery() + + host = _host(tmp_path, handler) + ctx = await _stored_context(host, task_store, stream=stream) + with pytest.raises(RuntimeError, match="ctx.exit_for_recovery.*ctx.shutdown.is_set"): + await host._endpoint._orchestrator._resilient_orchestrator._execute_in_task(ctx) + assert not ctx.shutdown.is_set() + assert not await host._endpoint._runtime_state.is_draining() + assert host._endpoint._runtime_state._reservations == set() + assert resilience._RUNTIME_REFS == {} diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_sse_writer.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_sse_writer.py index 32a6883fd7bc..a69ffa72aa53 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_sse_writer.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_sse_writer.py @@ -165,15 +165,18 @@ async def idle_source() -> AsyncIterator[str]: await stream.aclose() -async def test_with_keep_alive__propagates_source_error() -> None: +@pytest.mark.parametrize("error_type", [RuntimeError, asyncio.CancelledError]) +async def test_with_keep_alive__propagates_source_error( + error_type: type[BaseException], +) -> None: """An error raised by the source surfaces to the consumer after buffered items.""" async def source() -> AsyncIterator[str]: yield "a" - raise RuntimeError("boom") + raise error_type("boom") collected: List[str] = [] - with pytest.raises(RuntimeError, match="boom"): + with pytest.raises(error_type, match="boom"): async for item in with_keep_alive(source(), 5): collected.append(item) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_stream_lifecycle_cleanup.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_stream_lifecycle_cleanup.py new file mode 100644 index 000000000000..6f6f368b62ca --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_stream_lifecycle_cleanup.py @@ -0,0 +1,1108 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Admission, recovery, and pre-iteration cleanup of caller-scoped streams.""" + +from __future__ import annotations + +import asyncio +import json +from contextlib import suppress +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from anyio import CancelScope, lowlevel +from starlette.requests import Request + +from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + FoundryOriginalSubject, + get_request_context, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.core.streaming import EventStreamNotFoundError +from azure.ai.agentserver.core.platform_headers import PLATFORM_ERROR_TAG +from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry +from azure.ai.agentserver.core.tasks import LastInputIdPreconditionFailed, TaskConflictError +from azure.ai.agentserver.responses import ResponsesAgentServerHost, ResponsesServerOptions +from azure.ai.agentserver.responses import PlatformContext, ResponseContext +from azure.ai.agentserver.responses._id_generator import IdGenerator +from azure.ai.agentserver.responses.hosting import _endpoint_handler as endpoint +from azure.ai.agentserver.responses.hosting import _orchestrator as orchestration +from azure.ai.agentserver.responses.hosting import _resilient_orchestrator as resilience +from azure.ai.agentserver.responses.hosting import _routing as routing +from azure.ai.agentserver.responses.hosting._resilient_input import ResilientResponseInput +from azure.ai.agentserver.responses.hosting._task_id import derive_lifecycle_id +from azure.ai.agentserver.responses.models._generated import CreateResponse, ResponseObject +from azure.ai.agentserver.responses.models.runtime import ResponseExecution, ResponseModeFlags +from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider + + +RESPONSE_ID = IdGenerator.new_response_id() + + +@pytest.fixture +def registry(monkeypatch: pytest.MonkeyPatch) -> _StreamsRegistry: + value = _StreamsRegistry() + value.use_in_memory_replay(cursor_fn=lambda event: event["sequence_number"]) + for module in (endpoint, orchestration, resilience): + monkeypatch.setattr(module, "streams", value) + monkeypatch.setattr(routing, "_configure_streams_registry", lambda options: None) + monkeypatch.setattr(endpoint, "_flush_spans_for_mode", AsyncMock()) + monkeypatch.setattr(resilience, "_RUNTIME_REFS", {}) + return value + + +def _request(*, user: str = "owner", background: bool = True, stream: bool = True, store: bool = True) -> Request: + body = json.dumps( + { + "response_id": RESPONSE_ID, + "model": "m", + "input": "hi", + "store": store, + "stream": stream, + "background": background, + } + ).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "method": "POST", + "path": "/responses", + "query_string": b"", + "headers": [(b"content-type", b"application/json"), (b"x-agent-user-id", user.encode())], + } + return Request(scope, AsyncMock(return_value={"type": "http.request", "body": body, "more_body": False})) + + +def _host() -> ResponsesAgentServerHost: + host = ResponsesAgentServerHost( + options=ResponsesServerOptions(resilient_background=False, steerable_conversations=True), + store=InMemoryResponseProvider(), + ) + + async def handler(request: Any, context: Any, cancellation_signal: Any) -> Any: + raise AssertionError("rejected admission must not invoke the handler") + yield + + host.response_handler(handler) + return host + + +async def _send(message: dict[str, Any]) -> None: + pass + + +@pytest.mark.parametrize("cleanup_raises", [False, True]) +async def test_handler_early_close_keeps_attribution_bound_and_restores_caller(cleanup_raises): + subject = FoundryOriginalSubject(oid="handler-subject", tid="tenant", idtype="user") + caller = FoundryAgentRequestContext( + original_subject=FoundryOriginalSubject(oid="caller-subject", tid="tenant", idtype="user"), + call_id="caller-call", + ) + context = ResponseContext( + response_id=RESPONSE_ID, + mode_flags=ResponseModeFlags(stream=True, store=False, background=False), + platform_context=PlatformContext(original_subject=subject, call_id="handler-call", user_id_key="handler-user"), + ) + observed = [] + + async def handler(): + try: + observed.append(("prompt", get_request_context())) + yield {"type": "response.created"} + finally: + await asyncio.sleep(0) + observed.append(("cleanup", get_request_context())) + if cleanup_raises: + raise RuntimeError("handler cleanup failed") + + owned_iterator = handler() + iterator = orchestration._iter_handler_with_request_context( + lambda *_args: owned_iterator, {}, context, asyncio.Event(), "handler-session" + ) + token = set_request_context(caller) + try: + assert await iterator.__anext__() == {"type": "response.created"} + assert get_request_context() is caller + if cleanup_raises: + with pytest.raises(RuntimeError, match="handler cleanup failed"): + await iterator.aclose() + else: + await iterator.aclose() + + assert [stage for stage, _ in observed] == ["prompt", "cleanup"] + for _, request_context in observed: + assert request_context.original_subject is subject + assert request_context.call_id == "handler-call" + assert request_context.user_id == "handler-user" + assert request_context.session_id == "handler-session" + assert get_request_context() is caller + finally: + try: + with suppress(RuntimeError): + await iterator.aclose() + with suppress(RuntimeError): + await owned_iterator.aclose() + finally: + reset_request_context(token) + + +@pytest.mark.parametrize("store", [False, True]) +@pytest.mark.parametrize("failure", ["conflict", "handler"]) +async def test_sync_execution_error_preserves_error_response_and_releases_reservation( + registry, monkeypatch, store, failure +): + host = _host() + host._endpoint._response_headers["x-test-response"] = "preserved" + error = ( + TaskConflictError(current_status="in_progress") + if failure == "conflict" + else endpoint._HandlerError(RuntimeError("private handler details")) + ) + monkeypatch.setattr( + host._endpoint._orchestrator, + "run_sync", + AsyncMock(side_effect=error), + ) + + response = await host._endpoint.handle_create(_request(background=False, stream=False, store=store)) + + assert response.status_code == (409 if failure == "conflict" else 500) + assert response.headers["x-test-response"] == "preserved" + assert json.loads(response.body) == { + "error": { + "message": ( + "Conversation is locked \u2014 task is in_progress" + if failure == "conflict" + else "internal server error" + ), + "type": "conflict" if failure == "conflict" else "server_error", + "code": "conversation_locked" if failure == "conflict" else "server_error", + "param": None, + } + } + if failure == "handler": + assert response.headers["x-platform-error-source"] == "upstream" + state = host._endpoint._runtime_state + assert await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + + +@pytest.mark.parametrize( + "stream,background,store", + [ + (False, False, False), + (True, False, False), + (False, False, True), + (False, True, True), + (True, False, True), + (True, True, True), + ], + ids=["ephemeral-sync", "ephemeral-stream", "sync", "background", "foreground-stream", "background-stream"], +) +@pytest.mark.parametrize("timing", ["before-reserve", "after-reserve", "before-publication"]) +@pytest.mark.parametrize("file_backed", [False, True]) +async def test_shutdown_barrier_rejects_unpublished_execution_without_leaking_owned_state( + registry, monkeypatch, tmp_path, stream, background, store, timing, file_backed +): + host = _host() + state = host._endpoint._runtime_state + storage_dir = tmp_path / "replay" + if file_backed: + registry.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + other_id = derive_lifecycle_id(RESPONSE_ID, "other") + foreign = await registry.get_or_create(other_id) + await foreign.emit({"type": "response.created", "sequence_number": 0}) + assert await state.reserve(RESPONSE_ID, "other") + foreign_refs = {"foreign-incarnation": object()} + monkeypatch.setattr(resilience, "_RUNTIME_REFS", foreign_refs.copy()) + started = AsyncMock() + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", started) + entered = asyncio.Event() + resume = asyncio.Event() + reserve = state.reserve + + async def blocked_reserve(*args, **kwargs): + if timing == "before-reserve": + entered.set() + await resume.wait() + result = await reserve(*args, **kwargs) + if timing == "after-reserve": + assert result + entered.set() + await resume.wait() + return result + + monkeypatch.setattr(state, "reserve", blocked_reserve) + if timing == "before-publication": + method = "add_pending" if stream or not store else "add" + publish = getattr(state, method) + + async def blocked_publication(record, **kwargs): + entered.set() + await resume.wait() + return await publish(record, **kwargs) + + monkeypatch.setattr(state, method, blocked_publication) + request = _request(background=background, stream=stream, store=store) + creating = asyncio.create_task(host._endpoint.handle_create(request)) + sending = None + frames = [] + + async def send(message): + if message["type"] == "http.response.body" and message.get("body"): + frames.append(message["body"]) + + try: + if timing == "before-publication" and stream: + response = await asyncio.wait_for(creating, 2) + assert response.status_code == 200 + sending = asyncio.create_task(response.stream_response(send)) + await asyncio.wait_for(entered.wait(), 2) + assert await state.list_records() == [] + await asyncio.wait_for(host._endpoint.handle_shutdown(), 2) + resume.set() + if sending is not None: + await asyncio.wait_for(sending, 2) + assert len(frames) == 1 + error = json.loads(frames[0].decode().split("data: ", 1)[1].strip()) + assert error["type"] == "error" + assert error["code"] == "server_error" + else: + response = await asyncio.wait_for(creating, 2) + assert response.status_code == 503 + assert json.loads(response.body)["error"]["code"] == "service_unavailable" + started.assert_not_awaited() + assert await state.list_records() == [] + assert state._reservations == {("other", RESPONSE_ID)} + assert set(state._publication_contexts) == {("other", RESPONSE_ID)} + assert resilience._RUNTIME_REFS == foreign_refs + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + assert await registry.get(other_id) is foreign + assert [entry.payload for entry in foreign._buffer] == [{"type": "response.created", "sequence_number": 0}] + with pytest.raises(KeyError): + await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + if file_backed: + expected_files = [f"{other_id}.jsonl"] + if foreign._lock_path is not None: + expected_files.append(f"{other_id}.jsonl.lock") + assert sorted(path.name for path in storage_dir.iterdir()) == sorted(expected_files) + finally: + resume.set() + for task in (creating, sending): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(*(task for task in (creating, sending) if task is not None), return_exceptions=True) + await state.release_reservation(RESPONSE_ID, "other") + await registry.delete(other_id) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_shutdown_signals_and_awaits_ephemeral_handler_before_first_event(registry, stream): + host = _host() + entered = asyncio.Event() + finish = asyncio.Event() + contexts = [] + + async def handler(request, context, cancellation_signal): + contexts.append(context) + entered.set() + await context.shutdown.wait() + await finish.wait() + if False: + yield + + host.response_handler(handler) + creating = asyncio.create_task(host._endpoint.handle_create(_request(background=False, stream=stream, store=False))) + sending = None + shutting_down = None + try: + if stream: + response = await asyncio.wait_for(creating, 2) + sending = asyncio.create_task(response.stream_response(_send)) + await asyncio.wait_for(entered.wait(), 2) + state = host._endpoint._runtime_state + pending = state._pending_records[("owner", RESPONSE_ID)] + assert pending.response_context is contexts[0] + assert pending.execution_task is (sending if stream else creating) + shutting_down = asyncio.create_task(host._endpoint.handle_shutdown()) + await asyncio.wait_for(contexts[0].shutdown.wait(), 2) + assert not shutting_down.done() + finish.set() + await asyncio.wait_for(sending if stream else creating, 2) + await asyncio.wait_for(shutting_down, 2) + assert state._pending_records == {} + assert state._reservations == set() + assert state._publication_contexts == {} + assert resilience._RUNTIME_REFS == {} + assert registry._slots == {} + finally: + finish.set() + for task in (creating, sending, shutting_down): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather( + *(task for task in (creating, sending, shutting_down) if task is not None), return_exceptions=True + ) + + +@pytest.mark.parametrize("background", [False, True]) +async def test_shutdown_tracks_published_nonstream_startup_and_allows_exact_finalization( + registry, monkeypatch, background +): + host = _host() + entered = asyncio.Event() + finish = asyncio.Event() + records = [] + contexts = [] + + async def startup(ctx, record, fallback, *, disposition): + records.append(record) + contexts.append(ctx.context) + entered.set() + await finish.wait() + assert ctx.context.shutdown.is_set() + record.transition_to("completed") + assert await host._endpoint._runtime_state.add(record) + record.execution_task = asyncio.create_task(asyncio.sleep(0)) + await record.execution_task + record.response_created_signal.set() + + monkeypatch.setattr(host._endpoint._orchestrator, "_start_resilient_background", startup) + creating = asyncio.create_task(host._endpoint.handle_create(_request(background=background, stream=False))) + shutting_down = None + try: + await asyncio.wait_for(entered.wait(), 2) + assert records[0].execution_task is creating + shutting_down = asyncio.create_task(host._endpoint.handle_shutdown()) + await asyncio.wait_for(contexts[0].shutdown.wait(), 2) + assert not shutting_down.done() + finish.set() + if background: + assert (await asyncio.wait_for(creating, 2)).status_code == 200 + else: + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(creating, 2) + await asyncio.wait_for(shutting_down, 2) + assert await host._endpoint._runtime_state.get(RESPONSE_ID, "owner") is records[0] + assert records[0].status == "completed" + assert host._endpoint._runtime_state._reservations == set() + assert resilience._RUNTIME_REFS == {} + finally: + finish.set() + for task in (creating, shutting_down): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(*(task for task in (creating, shutting_down) if task is not None), return_exceptions=True) + + +async def test_nonstream_body_hands_drain_tracking_to_actual_execution_task(registry, monkeypatch): + record = ResponseExecution( + response_id=RESPONSE_ID, + mode_flags=ResponseModeFlags(stream=False, store=False, background=True), + status="in_progress", + user_id_key="owner", + ) + context = ResponseContext(response_id=RESPONSE_ID, mode_flags=record.mode_flags) + record.execution_task = asyncio.current_task() + entered = asyncio.Event() + finish = asyncio.Event() + + async def drain(*args, **kwargs): + entered.set() + await finish.wait() + return True + + monkeypatch.setattr(orchestration, "_bg_drain_handler_events", drain) + executing = asyncio.create_task( + orchestration._run_background_non_stream( + create_fn=AsyncMock(), + parsed=CreateResponse(model="m", input="hi"), + context=context, + cancellation_signal=record.cancel_signal, + record=record, + response_id=RESPONSE_ID, + agent_reference={}, + model="m", + store=False, + ) + ) + try: + await asyncio.wait_for(entered.wait(), 2) + assert record.execution_task is executing + assert not executing.done() + finally: + finish.set() + await asyncio.wait_for(executing, 2) + + +async def test_many_unstored_posts_do_not_retain_registry_lifecycle_locks( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + + async def complete(ctx: Any) -> dict[str, Any]: + assert not ctx.store + return {"id": ctx.response_id, "status": "completed", "output": []} + + monkeypatch.setattr(host._endpoint._orchestrator, "run_sync", complete) + monkeypatch.setattr(host._endpoint, "_monitor_disconnect", AsyncMock()) + for _ in range(250): + response_id = IdGenerator.new_response_id() + request = _request(stream=False, background=False) + body = json.dumps( + {"response_id": response_id, "model": "m", "input": "hi", "store": False, "stream": False} + ).encode() + request = Request( + request.scope, AsyncMock(return_value={"type": "http.request", "body": body, "more_body": False}) + ) + assert (await host._endpoint.handle_create(request)).status_code == 200 + assert registry._id_locks == {} + assert registry._slots == {} + assert host._endpoint._runtime_state._reservations == set() + assert host._endpoint._runtime_state._publication_contexts == {} + + +@pytest.mark.parametrize("background", [False, True]) +async def test_cancelled_anyio_scope_releases_non_streaming_reservation_exactly_once( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, background: bool +) -> None: + host = _host() + state = host._endpoint._runtime_state + assert await state.reserve(RESPONSE_ID, "other") + original_release = state.release_reservation + releasing = asyncio.Event() + released = asyncio.Event() + calls: list[tuple[str, str | None]] = [] + scope = CancelScope() + + async def release(response_id: str, user_id_key: str | None) -> None: + calls.append((response_id, user_id_key)) + releasing.set() + await original_release(response_id, user_id_key) + # A successor may claim the same ID immediately after cleanup. The old + # request must not release that successor's reservation a second time. + assert await state.reserve(response_id, user_id_key) + released.set() + + async def unlock() -> None: + await releasing.wait() + await asyncio.sleep(0) + state._lock.release() + + async def cancelled_execution(ctx: Any) -> dict[str, Any]: + # Force release_reservation to wait on its real state lock rather than + # taking the uncontended, checkpoint-free asyncio.Lock fast path. + await state._lock.acquire() + scope.cancel() + await lowlevel.checkpoint() + raise AssertionError("the cancelled request must not finish execution") + + method = "run_background" if background else "run_sync" + monkeypatch.setattr(host._endpoint._orchestrator, method, cancelled_execution) + monkeypatch.setattr(state, "release_reservation", release) + release_task = asyncio.create_task(unlock()) + try: + with scope: + await host._endpoint.handle_create(_request(background=background, stream=False)) + await asyncio.wait_for(release_task, 2) + assert scope.cancelled_caught + assert calls == [(RESPONSE_ID, "owner")] + assert released.is_set() + assert not await state.reserve(RESPONSE_ID, "owner") + assert not await state.reserve(RESPONSE_ID, "other") + conflict = await host._endpoint.handle_create(_request(background=background, stream=False)) + assert conflict.status_code == 409 + assert calls == [(RESPONSE_ID, "owner")] + assert not await state.reserve(RESPONSE_ID, "owner") + await original_release(RESPONSE_ID, "owner") + monkeypatch.setattr(state, "release_reservation", original_release) + monkeypatch.setattr( + host._endpoint._orchestrator, + method, + AsyncMock(return_value={"id": RESPONSE_ID, "status": "completed", "output": []}), + ) + retry = await host._endpoint.handle_create(_request(background=background, stream=False)) + assert retry.status_code == 200 + assert await state.reserve(RESPONSE_ID, "owner") + assert not await state.reserve(RESPONSE_ID, "other") + await original_release(RESPONSE_ID, "owner") + finally: + releasing.set() + await asyncio.gather(release_task, return_exceptions=True) + await original_release(RESPONSE_ID, "other") + + +@pytest.mark.parametrize("failure", ["conflict", "precondition", "cancel"]) +async def test_rejected_admission_deletes_new_stream_before_releasing_reservation( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + host = _host() + state = host._endpoint._runtime_state + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + other_id = derive_lifecycle_id(RESPONSE_ID, "other") + other = await registry.get_or_create(other_id) + cleanup_started = asyncio.Event() + finish_cleanup = asyncio.Event() + original_delete = registry.delete + + async def delete(stream_id: str) -> None: + assert stream_id == lifecycle_id + cleanup_started.set() + await finish_cleanup.wait() + await original_delete(stream_id) + + async def reject(*args: Any, **kwargs: Any) -> bool: + if failure == "cancel": + raise asyncio.CancelledError() + if failure == "precondition": + raise LastInputIdPreconditionFailed(actual_last_input_id="latest") + raise TaskConflictError(current_status="in_progress") + + monkeypatch.setattr(registry, "delete", delete) + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", reject) + response = await host._endpoint.handle_create(_request()) + sending = asyncio.create_task(response.stream_response(_send)) + try: + await asyncio.wait_for(cleanup_started.wait(), 2) + assert await state.list_records() == [] + competing = await host._endpoint.handle_create(_request()) + assert competing.status_code == 409 + assert json.loads(competing.body)["error"]["code"] == "response_id_conflict" + finally: + finish_cleanup.set() + expected = { + "conflict": TaskConflictError, + "precondition": LastInputIdPreconditionFailed, + "cancel": asyncio.CancelledError, + }[failure] + with pytest.raises(expected): + await asyncio.wait_for(sending, 2) + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + assert await registry.get(other_id) is other + assert await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + retry = await host._endpoint.handle_create(_request()) + assert retry.status_code == 200 + + # Headers fail before lazy task admission, so this second create allocates no stream. + async def fail_headers(message: dict[str, Any]) -> None: + raise OSError("headers failed") + + with pytest.raises(OSError): + await retry.stream_response(fail_headers) + assert await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + + +async def test_rejected_admission_preserves_preexisting_stream( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + response = await host._endpoint.handle_create(_request()) + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + existing = await registry.get_or_create(lifecycle_id) + await existing.emit({"sequence_number": 0, "type": "response.created"}) + monkeypatch.setattr( + resilience.ResilientResponseOrchestrator, + "start_resilient", + AsyncMock(side_effect=TaskConflictError(current_status="in_progress")), + ) + with pytest.raises(TaskConflictError): + await response.stream_response(_send) + assert await registry.get(lifecycle_id) is existing + await existing.close() + assert [event async for event in existing.subscribe()] == [{"sequence_number": 0, "type": "response.created"}] + + +@pytest.mark.parametrize("file_backed", [False, True]) +@pytest.mark.parametrize("failure", ["conflict", "precondition"]) +@pytest.mark.parametrize("ownership", ["new", "preexisting", "started"]) +async def test_tagged_platform_start_error_is_http_only_and_preserves_stream_ownership( + registry: _StreamsRegistry, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + file_backed: bool, + failure: str, + ownership: str, +) -> None: + host = _host() + state = host._endpoint._runtime_state + provider = host._endpoint._provider + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + other_id = derive_lifecycle_id(RESPONSE_ID, "other") + storage_dir = tmp_path / "replay" + if file_backed: + registry.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + other = await registry.get_or_create(other_id) + response = await host._endpoint.handle_create(_request()) + existing = None + if ownership == "preexisting": + existing = await registry.get_or_create(lifecycle_id) + await existing.emit({"type": "response.created", "sequence_number": 0}) + release_execution = asyncio.Event() + execution: asyncio.Task[Any] | None = None + started_record = None + attempts = 0 + + async def start(*args: Any, record: Any, **kwargs: Any) -> bool: + nonlocal attempts, execution, started_record + attempts += 1 + if attempts == 1: + if ownership == "started": + await record.subject.emit({"type": "response.created", "sequence_number": 0}) + execution = asyncio.create_task(release_execution.wait()) + record.execution_task = execution + record.resilient_task_run = SimpleNamespace(is_queued=False) + started_record = record + await state.add(record) + error = ( + TaskConflictError(current_status="in_progress") + if failure == "conflict" + else LastInputIdPreconditionFailed(actual_last_input_id="latest") + ) + setattr(error, PLATFORM_ERROR_TAG, True) + raise error + snapshot = {"id": RESPONSE_ID, "object": "response", "status": "completed", "background": True, "output": []} + await provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + await record.subject.emit({"type": "response.created", "sequence_number": 0}) + await record.subject.emit( + {"type": "response.completed", "sequence_number": 1, "response": snapshot}, close=True + ) + record.resilient_task_run = SimpleNamespace(is_queued=False) + await state.discard_pending(RESPONSE_ID, "owner") + return True + + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", start) + frames: list[bytes] = [] + + async def send(message: dict[str, Any]) -> None: + if message["type"] == "http.response.body" and message.get("body"): + frames.append(message["body"]) + + try: + await asyncio.wait_for(response.stream_response(send), 2) + error = json.loads(frames[0].decode().split("data: ", 1)[1].strip()) + assert error["type"] == "error" + assert error["code"] == "server_error" + assert await registry.get(other_id) is other + await other.emit({"type": "response.in_progress", "sequence_number": 0}) + if ownership == "new": + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + assert await state.list_records() == [] + with pytest.raises(KeyError): + await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + if file_backed: + assert not (storage_dir / f"{lifecycle_id}.jsonl").exists() + assert not (storage_dir / f"{lifecycle_id}.jsonl.lock").exists() + frames.clear() + retry = await host._endpoint.handle_create(_request()) + assert retry.status_code == 200 + # File-backed retry flushes both events before returning the stream. + await asyncio.wait_for(retry.stream_response(send), 10 if file_backed else 2) + assert attempts == 2 + assert b"response.completed" in b"".join(frames) + persisted = await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert persisted["status"] == "completed" + else: + retained = await registry.get(lifecycle_id) + if ownership == "preexisting": + assert retained is existing + else: + assert execution is not None and not execution.done() + assert await state.get(RESPONSE_ID, "owner") is started_record + assert retained is started_record.subject + await retained.emit({"type": "response.completed", "sequence_number": 1}, close=True) + events = [event async for event in retained.subscribe()] + assert [event["type"] for event in events] == ["response.created", "response.completed"] + finally: + release_execution.set() + if execution is not None: + await asyncio.wait_for(execution, 2) + await registry.delete(lifecycle_id) + await registry.delete(other_id) + + +@pytest.mark.parametrize("file_backed", [False, True]) +@pytest.mark.parametrize("send_error", [False, True]) +async def test_shutdown_rejection_deletes_new_replay_before_yield_and_reservation_release( + registry: _StreamsRegistry, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + file_backed: bool, + send_error: bool, +) -> None: + host = _host() + state = host._endpoint._runtime_state + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + other_id = derive_lifecycle_id(RESPONSE_ID, "other") + storage_dir = tmp_path / "replay" + if file_backed: + registry.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + other = await registry.get_or_create(other_id) + started = AsyncMock() + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", started) + response = await host._endpoint.handle_create(_request()) + await state.begin_draining() + deleting = asyncio.Event() + release_delete = asyncio.Event() + original_delete = registry.delete + frames: list[bytes] = [] + + async def delete(stream_id: str) -> None: + assert stream_id == lifecycle_id + deleting.set() + await release_delete.wait() + await original_delete(stream_id) + + async def send(message: dict[str, Any]) -> None: + if message["type"] == "http.response.body" and message.get("body"): + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + frames.append(message["body"]) + if send_error: + raise OSError("client disconnected") + + monkeypatch.setattr(registry, "delete", delete) + sending = asyncio.create_task(response.stream_response(send)) + try: + await asyncio.wait_for(deleting.wait(), 2) + assert not frames + assert not await state.reserve(RESPONSE_ID, "owner") + competing = await host._endpoint.handle_create(_request()) + assert competing.status_code == 503 + assert json.loads(competing.body)["error"]["code"] == "service_unavailable" + assert await state.list_records() == [] + finally: + release_delete.set() + if send_error: + with pytest.raises(OSError, match="client disconnected"): + await asyncio.wait_for(sending, 2) + else: + await asyncio.wait_for(sending, 2) + started.assert_not_awaited() + error = json.loads(frames[0].decode().split("data: ", 1)[1].strip()) + assert error["type"] == "error" + assert error["code"] == "server_error" + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + assert await registry.get(other_id) is other + assert not await state.reserve(RESPONSE_ID, "owner") + assert state._reservations == set() + with pytest.raises(KeyError): + await host._endpoint._provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + if file_backed: + # Only the other user's log remains; failed admission left no cleanup owner. + assert len(list(storage_dir.glob("*.jsonl"))) == 1 + await original_delete(other_id) + + +@pytest.mark.parametrize("existing_stream", [False, True]) +async def test_shutdown_rejection_preserves_existing_stream_and_started_record( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, existing_stream: bool +) -> None: + host = _host() + state = host._endpoint._runtime_state + response = await host._endpoint.handle_create(_request()) + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + active = await registry.get_or_create(lifecycle_id) if existing_stream else None + if active is not None: + await active.emit({"type": "response.created", "sequence_number": 0}) + record = ResponseExecution( + response_id=RESPONSE_ID, + user_id_key="owner", + mode_flags=ResponseModeFlags(stream=True, store=True, background=True), + status="in_progress", + subject=active, + response_context=state._publication_contexts[("owner", RESPONSE_ID)], + ) + running = asyncio.create_task(asyncio.Event().wait()) + record.execution_task = running + assert await state.add(record) + await state.begin_draining() + started = AsyncMock() + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", started) + delete = AsyncMock(wraps=registry.delete) + monkeypatch.setattr(registry, "delete", delete) + try: + await asyncio.wait_for(response.stream_response(_send), 2) + started.assert_not_awaited() + delete.assert_not_awaited() + assert await state.get(RESPONSE_ID, "owner") is record + assert not running.done() + retained = await registry.get(lifecycle_id) + if active is not None: + assert retained is active + else: + active = retained + await active.emit({"type": "response.created", "sequence_number": 0}) + await active.emit({"type": "response.completed", "sequence_number": 1}, close=True) + assert [event["type"] async for event in active.subscribe()] == ["response.created", "response.completed"] + finally: + running.cancel() + await asyncio.gather(running, return_exceptions=True) + + +@pytest.mark.parametrize("background", [False, True]) +@pytest.mark.parametrize("failure", ["error", "cancel"]) +async def test_header_failure_stops_monitor_and_releases_exactly_once( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, background: bool, failure: str +) -> None: + host = _host() + state = host._endpoint._runtime_state + monitors: list[asyncio.Task[Any]] = [] + + async def monitor(request: Any, signal: asyncio.Event, **kwargs: Any) -> None: + monitors.append(asyncio.current_task()) + await signal.wait() + + monkeypatch.setattr(host._endpoint, "_monitor_disconnect", monitor) + release = AsyncMock(wraps=state.release_reservation) + monkeypatch.setattr(state, "release_reservation", release) + response = await host._endpoint.handle_create(_request(background=background)) + + async def fail_headers(message: dict[str, Any]) -> None: + assert message["type"] == "http.response.start" + if failure == "cancel": + raise asyncio.CancelledError() + raise OSError("headers failed") + + with pytest.raises(asyncio.CancelledError if failure == "cancel" else OSError): + await response.stream_response(fail_headers) + assert release.await_count == 1 + assert all(task.done() for task in monitors) + assert len(monitors) == (0 if background else 1) + assert await state.reserve(RESPONSE_ID, "owner") + # Re-entering finalization must not release a later caller's reservation. + with pytest.raises(asyncio.CancelledError if failure == "cancel" else OSError): + await response.stream_response(fail_headers) + assert release.await_count == 1 + assert not await state.reserve(RESPONSE_ID, "owner") + await state.release_reservation(RESPONSE_ID, "owner") + with pytest.raises(EventStreamNotFoundError): + await registry.get(derive_lifecycle_id(RESPONSE_ID, "owner")) + + +@pytest.mark.parametrize("cancel_admission", [False, True]) +async def test_started_background_execution_survives_failed_http_request( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, cancel_admission: bool +) -> None: + host = _host() + complete = asyncio.Event() + execution: asyncio.Task[Any] | None = None + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + + async def start(*args: Any, record: Any, **kwargs: Any) -> bool: + nonlocal execution + + async def run() -> None: + await record.subject.emit({"type": "response.created", "sequence_number": 0}) + await complete.wait() + await record.subject.emit({"type": "response.completed", "sequence_number": 1}, close=True) + + execution = asyncio.create_task(run()) + record.execution_task = execution + record.resilient_task_run = SimpleNamespace(is_queued=False) + if cancel_admission: + raise asyncio.CancelledError() + return True + + monkeypatch.setattr(resilience.ResilientResponseOrchestrator, "start_resilient", start) + response = await host._endpoint.handle_create(_request()) + + async def fail_body(message: dict[str, Any]) -> None: + if message["type"] == "http.response.body": + raise OSError("body failed") + + try: + with pytest.raises(asyncio.CancelledError if cancel_admission else OSError): + await asyncio.wait_for(response.stream_response(fail_body), 2) + assert execution is not None and not execution.done() + retained = await registry.get(lifecycle_id) + finally: + complete.set() + if execution is not None: + await asyncio.wait_for(execution, 2) + assert [event["type"] async for event in retained.subscribe()] == ["response.created", "response.completed"] + competing = await host._endpoint.handle_create(_request()) + assert competing.status_code == 409 + + +@pytest.mark.parametrize("terminal", [None, "completed", "failed"]) +async def test_cold_mark_failed_recovery_finishes_replay_and_ttl( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, terminal: str | None +) -> None: + host = _host() + provider = host._endpoint._provider + original = _StreamsRegistry() + original.use_file_backed_replay( + storage_dir=tmp_path, cursor_fn=lambda event: event["sequence_number"], ttl_seconds=10 + ) + response_id = RESPONSE_ID + lifecycle_id = derive_lifecycle_id(response_id, "owner") + other_id = derive_lifecycle_id(response_id, "other") + snapshot = ResponseObject( + { + "id": response_id, + "status": terminal or "in_progress", + "object": "response", + "background": True, + "output": [], + "agent_reference": {"type": "agent_reference", "name": "agent", "version": "1"}, + } + ) + await provider.create_response(snapshot, [], None, context=PlatformContext(user_id_key="owner")) + unfinished = await original.get_or_create(lifecycle_id) + await unfinished.emit({"type": "response.created", "sequence_number": 4, "response": {"id": response_id}}) + unfinished._cleanup_locks() + other = await original.get_or_create(other_id) + await other.emit({"type": "response.created", "sequence_number": 0}) + other._cleanup_locks() + registry.use_file_backed_replay( + storage_dir=tmp_path, cursor_fn=lambda event: event["sequence_number"], ttl_seconds=10 + ) + orch = resilience.ResilientResponseOrchestrator( + create_fn=AsyncMock(), provider=provider, options=ResponsesServerOptions() + ) + params = ResilientResponseInput( + request=CreateResponse({"input": "hi", "background": True, "store": True, "stream": True}), + response_id=response_id, + disposition="mark-failed", + user_id_key="owner", + ).to_task_input() + try: + assert await orch._handle_recovery_disposition( + disposition="mark-failed", is_recovery=True, response_id=response_id, params=params, background=True + ) + recovered = await registry.get(lifecycle_id) + + async def collect() -> list[dict[str, Any]]: + return [event async for event in recovered.subscribe()] + + events = await asyncio.wait_for(collect(), 2) + assert events[-1]["type"] == f"response.{terminal or 'failed'}" + assert events[-1]["sequence_number"] == 5 + persisted = await provider.get_response(response_id, context=PlatformContext(user_id_key="owner")) + assert persisted["status"] == (terminal or "failed") + await orch._persist_crash_failed(response_id, params) + assert len(await asyncio.wait_for(collect(), 2)) == 2 + request = _request() + request.scope.update( + method="GET", + path=f"/responses/{response_id}", + query_string=b"stream=true", + path_params={"response_id": response_id}, + ) + replay = await host._endpoint.handle_get(request) + assert replay.status_code == 200 + frames: list[bytes] = [] + + async def send_replay(message: dict[str, Any]) -> None: + if message["type"] == "http.response.body": + frames.append(message.get("body", b"")) + + await asyncio.wait_for(replay.stream_response(send_replay), 2) + assert f"response.{terminal or 'failed'}".encode() in b"".join(frames) + other_replay = await registry.get(other_id) + await other_replay.emit({"type": "response.in_progress", "sequence_number": 1}) + # Advancing the clock, rather than sleeping, verifies close-clock expiry. + from azure.ai.agentserver.core.streaming import _concrete + + monkeypatch.setattr(_concrete.time, "time", lambda: recovered._close_time + 11) + with pytest.raises(EventStreamNotFoundError): + await registry.get(lifecycle_id) + assert len(list(tmp_path.glob("*.jsonl"))) == 1 + finally: + await registry.delete(lifecycle_id) + await registry.delete(other_id) + + +async def test_resumable_recovery_keeps_active_replay(registry: _StreamsRegistry) -> None: + host = _host() + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + active = await registry.get_or_create(lifecycle_id) + orch = resilience.ResilientResponseOrchestrator( + create_fn=AsyncMock(), provider=host._endpoint._provider, options=ResponsesServerOptions() + ) + assert not await orch._handle_recovery_disposition( + disposition="re-invoke", is_recovery=True, response_id=RESPONSE_ID, params={}, background=True + ) + assert await registry.get(lifecycle_id) is active + await active.emit({"type": "response.completed", "sequence_number": 1}, close=True) + assert [event["type"] async for event in active.subscribe()] == ["response.completed"] + + +@pytest.mark.parametrize("failure", ["read", "write"]) +async def test_uncertain_terminal_persistence_keeps_replay_active( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + host = _host() + lifecycle_id = derive_lifecycle_id(RESPONSE_ID, "owner") + active = await registry.get_or_create(lifecycle_id) + provider = host._endpoint._provider + await provider.create_response( + {"id": RESPONSE_ID, "status": "in_progress", "output": []}, + [], + None, + context=PlatformContext(user_id_key="owner"), + ) + if failure == "read": + monkeypatch.setattr(provider, "get_response", AsyncMock(side_effect=RuntimeError("store unavailable"))) + else: + monkeypatch.setattr(provider, "update_response", AsyncMock(side_effect=RuntimeError("write rejected"))) + orch = resilience.ResilientResponseOrchestrator( + create_fn=AsyncMock(), provider=provider, options=ResponsesServerOptions() + ) + await orch._persist_crash_failed(RESPONSE_ID, {"user_id_key": "owner"}) + await active.emit({"type": "response.in_progress", "sequence_number": 0}) + assert await registry.get(lifecycle_id) is active + await active.close() + assert [event["type"] async for event in active.subscribe()] == ["response.in_progress"] + + +async def test_terminal_replay_cleanup_error_does_not_overwrite_completed_storage( + registry: _StreamsRegistry, monkeypatch: pytest.MonkeyPatch +) -> None: + host = _host() + provider = host._endpoint._provider + await provider.create_response( + {"id": RESPONSE_ID, "status": "completed", "output": []}, + [], + None, + context=PlatformContext(user_id_key="owner"), + ) + await registry.get_or_create(derive_lifecycle_id(RESPONSE_ID, "owner")) + monkeypatch.setattr(registry, "get", AsyncMock(side_effect=OSError("replay unavailable"))) + update = AsyncMock(wraps=provider.update_response) + monkeypatch.setattr(provider, "update_response", update) + orch = resilience.ResilientResponseOrchestrator( + create_fn=AsyncMock(), provider=provider, options=ResponsesServerOptions() + ) + with pytest.raises(OSError, match="replay unavailable"): + await orch._persist_crash_failed(RESPONSE_ID, {"user_id_key": "owner"}) + update.assert_not_awaited() + persisted = await provider.get_response(RESPONSE_ID, context=PlatformContext(user_id_key="owner")) + assert persisted["status"] == "completed" + + +async def test_mark_failed_recovery_never_allocates_an_absent_replay( + registry: _StreamsRegistry, tmp_path: Path +) -> None: + host = _host() + storage_dir = tmp_path / "replay" + registry.use_file_backed_replay(storage_dir=storage_dir, cursor_fn=lambda event: event["sequence_number"]) + orch = resilience.ResilientResponseOrchestrator( + create_fn=AsyncMock(), provider=host._endpoint._provider, options=ResponsesServerOptions() + ) + await orch._persist_crash_failed(RESPONSE_ID, {"user_id_key": "owner"}) + assert not list(storage_dir.iterdir()) + with pytest.raises(EventStreamNotFoundError): + await registry.get(derive_lifecycle_id(RESPONSE_ID, "owner")) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_streams_bootstrap.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_streams_bootstrap.py index f2e0a561ac47..3370fd749842 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_streams_bootstrap.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_streams_bootstrap.py @@ -43,6 +43,7 @@ def _isolate_streams_registry() -> Iterator[None]: saved_slots = dict(streams._slots) # type: ignore[attr-defined] saved_locks = dict(streams._id_locks) # type: ignore[attr-defined] saved_factory = streams._factory # type: ignore[attr-defined] + saved_restore = streams._restore streams._slots.clear() # type: ignore[attr-defined] streams._id_locks.clear() # type: ignore[attr-defined] streams.use_in_memory_live() @@ -54,6 +55,7 @@ def _isolate_streams_registry() -> Iterator[None]: streams._id_locks.clear() # type: ignore[attr-defined] streams._id_locks.update(saved_locks) # type: ignore[attr-defined] streams._factory = saved_factory # type: ignore[attr-defined] + streams._restore = saved_restore @pytest.mark.asyncio diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_task_id.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_task_id.py index 4cd83562d33f..d19be3135bfb 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_task_id.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_task_id.py @@ -4,14 +4,56 @@ from __future__ import annotations +import pytest + from azure.ai.agentserver.responses._id_generator import IdGenerator from azure.ai.agentserver.responses.hosting._chain_id import derive_conversation_chain_id from azure.ai.agentserver.responses.hosting._task_id import ( + derive_lifecycle_id, derive_task_id, derive_task_session_scope, ) +def test_lifecycle_id_keeps_anonymous_compatibility_and_separates_user_keys(): + response_id = "caresp_shared" + assert derive_lifecycle_id(response_id, None) == response_id + keys = [derive_lifecycle_id(response_id, user) for user in [None, "", "anonymous", "user-A", "user-B", "\u00e9"]] + assert len(set(keys)) == len(keys) + assert derive_lifecycle_id("bc", "a") != derive_lifecycle_id("c", "ab") + assert derive_lifecycle_id(response_id, "user-A") == derive_lifecycle_id(response_id, "user-A") + + +@pytest.mark.asyncio +async def test_user_scoped_file_replay_survives_restart_without_legacy_fallback(tmp_path): + from azure.ai.agentserver.core.streaming._registry import _StreamsRegistry + from azure.ai.agentserver.core.streaming import EventStreamNotFoundError + + response_id = "caresp_shared" + first = _StreamsRegistry() + first.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=lambda event: event["sequence_number"]) + for user in [None, "user-A", "user-B"]: + stream = await first.get_or_create(derive_lifecycle_id(response_id, user)) + await stream.emit({"sequence_number": 0, "owner": user}) + await stream.close() + # Release process-owned handles without deleting the persisted replay. + stream._cleanup_locks() + + restarted = _StreamsRegistry() + restarted.use_file_backed_replay(storage_dir=tmp_path, cursor_fn=lambda event: event["sequence_number"]) + for user in [None, "user-A", "user-B"]: + stream = await restarted.get(derive_lifecycle_id(response_id, user)) + events = [event async for event in stream.subscribe()] + assert events == [{"sequence_number": 0, "owner": user}] + await restarted.delete(derive_lifecycle_id(response_id, "user-A")) + with pytest.raises(EventStreamNotFoundError): + await restarted.get(derive_lifecycle_id(response_id, "user-A")) + other = await restarted.get(derive_lifecycle_id(response_id, "user-B")) + assert [event async for event in other.subscribe()] == [{"sequence_number": 0, "owner": "user-B"}] + await restarted.delete(derive_lifecycle_id(response_id, "user-B")) + await restarted.delete(response_id) + + class TestTaskIdDerivation: """Verify deterministic task ID generation.""" @@ -198,10 +240,7 @@ def test_task_session_scope_includes_guid_and_public_session(self) -> None: assert first != second def test_task_session_scope_without_guid_is_legacy_session(self) -> None: - assert ( - derive_task_session_scope(session_id="public-session", session_guid=None) - == "public-session" - ) + assert derive_task_session_scope(session_id="public-session", session_guid=None) == "public-session" def test_parallel_forks_get_distinct_ids(self) -> None: """Two requests with same previous_response_id but steerable=False diff --git a/sdk/agentserver/ci.yml b/sdk/agentserver/ci.yml index 2f6abbe89fdc..8c42b1145a1e 100644 --- a/sdk/agentserver/ci.yml +++ b/sdk/agentserver/ci.yml @@ -1,5 +1,18 @@ # NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file. +parameters: + - name: PublishA2AAlpha + displayName: Publish A2A and core alpha packages to the dev feed (internal manual runs only) + type: boolean + default: false + +variables: + - ${{ if and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }}: + - name: SetDevVersion + value: 'true' + - name: Skip.Release + value: 'true' + trigger: branches: include: @@ -37,14 +50,25 @@ extends: Path: sdk/agentserver/platform-matrix.json Selection: sparse GenerateVMJobs: true + # Namespace metadata generation reads this list as raw YAML; keep every entry named. Artifacts: + - name: azure-ai-agentserver-a2a + safeName: azureaiagentservera2a - name: azure-ai-agentserver-core safeName: azureaiagentservercore - name: azure-ai-agentserver-invocations safeName: azureaiagentserverinvocations + skipPublishDevFeed: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} + skipPublishDocMs: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} - name: azure-ai-agentserver-responses safeName: azureaiagentserverresponses + skipPublishDevFeed: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} + skipPublishDocMs: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} - name: azure-ai-agentserver-optimization safeName: azureaiagentserveroptimization + skipPublishDevFeed: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} + skipPublishDocMs: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} - name: azure-ai-agentserver-activity safeName: azureaiagentserveractivity + skipPublishDevFeed: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} + skipPublishDocMs: ${{ and(parameters.PublishA2AAlpha, eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual')) }} diff --git a/sdk/agentserver/cspell.yaml b/sdk/agentserver/cspell.yaml index 291fa9434220..dc206ac6d3d6 100644 --- a/sdk/agentserver/cspell.yaml +++ b/sdk/agentserver/cspell.yaml @@ -26,6 +26,7 @@ words: - BYOK - cand - caplog + - capsys - caresp - cchain - configurators @@ -59,6 +60,7 @@ words: - hypercorn - idless - idtoken + - idtype - impls - injectively - inmemory @@ -114,6 +116,10 @@ words: - xapitoken - xhigh ignorePaths: + - sdk/agentserver/azure-ai-agentserver-a2a/**/*.csv + - sdk/agentserver/azure-ai-agentserver-a2a/**/*.json + - sdk/agentserver/azure-ai-agentserver-a2a/**/*.rst + - sdk/agentserver/azure-ai-agentserver-a2a/samples/** - sdk/agentserver/azure-ai-agentserver-activity/**/*.csv - sdk/agentserver/azure-ai-agentserver-activity/**/*.json - sdk/agentserver/azure-ai-agentserver-activity/**/*.rst diff --git a/sdk/ai/azure-ai-projects/.github/skills/README.md b/sdk/ai/azure-ai-projects/.github/skills/README.md index b48751648bf2..558c0af9dac7 100644 --- a/sdk/ai/azure-ai-projects/.github/skills/README.md +++ b/sdk/ai/azure-ai-projects/.github/skills/README.md @@ -57,6 +57,16 @@ This skill compares a newly emitted or merged public API surface with its select This skill updates existing pytest coverage and authors complete sync/async Test Proxy tests for new behavior. New recorded service tests remain enabled so missing-recording errors surface in PR validation and authors can contact the SDK maintainer for help. +### azure-ai-projects-record-sample + +This skill prompts for record-and-playback, record-only, playback-only, or live test locally, then runs one parameterized sample test. It scans required environment variables and passes effective `.env` values as pytest arguments. + ### azure-ai-projects-model-reference-graph This skill generates structured and readable graphs that trace every generated model through direct references, inheritance, runtime ownership, and public operation methods. It supports model-usage investigation and TypeSpec removal analysis without treating static reachability as proof that a model is safe to remove. + +### azure-ai-projects-run-issue-regeneration + +This skill orchestrates an unattended TypeSpec regeneration for a GitHub issue already assigned to Copilot (an issue labeled with a `TypeSpec commit` and `Base branch`). It validates those inputs, then runs `azure-ai-projects-emit-from-typespec`, `azure-ai-projects-author-samples`, and `azure-ai-projects-author-tests` in order against the issue assignment's existing working branch and draft pull request, instead of the interactive branch/PR creation those skills use for a human developer. Use the other skills directly for interactive, local regeneration. + +Changelog updates are excluded from this workflow. The SDK maintainer runs `azure-ai-projects-update-changelog` separately at their discretion. diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/SKILL.md b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/SKILL.md new file mode 100644 index 000000000000..f6f9a9020d8a --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/SKILL.md @@ -0,0 +1,87 @@ +--- +name: azure-ai-projects-record-sample +description: 'Record, play back, or run an azure-ai-projects Python sample live with the Test Proxy. WHEN: record sample, rerecord sample, record and playback, record only, playback only, live test locally, validate recording. Always prompts for an execution mode. Scans required environment variables and passes effective .env values as pytest command-line arguments. DO NOT USE FOR: authoring tests or samples; non-sample service tests; other packages.' +argument-hint: 'Sample path or node ID; choose mode when prompted' +--- + +# Record and verify an azure-ai-projects sample + +Run from `sdk\ai\azure-ai-projects`. This workflow may modify Test Proxy recordings. Never commit `.env`, credentials, access tokens, API keys, or unsanitized resource identifiers. + +## Execution modes + +Before previewing, always use the question tool to ask the user to choose exactly one mode. Do this even when the request appears to imply a mode; never infer or default it. + +| Mode | `AZURE_TEST_RUN_LIVE` | `AZURE_SKIP_LIVE_RECORDING` | Behavior | +| --- | --- | --- | --- | +| `record-and-playback` | `true`, then `false` | `false`, then `true` | Record, then verify playback even if recording fails. | +| `record-only` | `true` | `false` | Record without running playback. | +| `playback-only` | `false` | `true` | Run only from an existing recording. | +| `live-test-locally` | `true` | `true` | Contact the live service locally without creating or updating a recording. | + +Do not silently substitute one mode for another. Include the selected mode in the final report. + +## 1. Preview the plan + +Run the orchestrator without `-Run`: + +```powershell +.\.github\skills\azure-ai-projects-record-sample\scripts\InvokeSampleTest.ps1 ` + -SamplePath "" ` + -Mode "" +``` + +It deterministically resolves the collected pytest node, scans required sample variables, detects selected `AdditionalSampleTestDetail` values and LLM validation, reads effective `.env` assignments, maps supported pytest options, selects mode flags, and prints a plan. The plan includes the resolved sample path, pytest node, and effective environment variables as `key=value` entries. Pytest command previews remain redacted. Treat the preview as sensitive and do not paste it into reports or source files. + +If multiple nodes execute the sample, the script lists them and stops. Ask the user which scenario to use, then rerun preview with `-NodeId ""`. If a required name is missing or empty, report the name and remind the user to check `.env`; do not invent a value. + +Lower-level scripts under `scripts\` are for diagnosing the orchestrator only. Do not repeat their work manually during a normal run. + +## 2. Obtain approval + +For `record-and-playback`, `record-only`, and `live-test-locally`, use the question tool to ask for input confirmation immediately before execution: + +**Confirm inputs:** "Use sample ``, pytest node ``, and the displayed `key=value` environment variables?" + +Include the preview's environment entries plus missing and empty variable names in the question's supporting message. If the user does not confirm, update the requested sample, node, or `.env` as directed and rerun preview before asking again. + +Also ask the action-specific approval in the same question-tool call: + +- For `record-and-playback` and `record-only`: **Approve recording:** "Start recording now? It may update Test Proxy recordings." +- For `live-test-locally`: **Approve live test:** "Start the live test locally without recording now?" + +Do not run unless both input confirmation and action approval are `Yes`. `playback-only` needs neither confirmation. + +## 3. Execute + +Use the same sample, selected node, and mode from preview: + +```powershell +# record-and-playback or record-only +.\.github\skills\azure-ai-projects-record-sample\scripts\InvokeSampleTest.ps1 ` + -SamplePath "" -NodeId "" -Mode "" ` + -Run -ConfirmSelection -ConfirmRecording + +# playback-only +.\.github\skills\azure-ai-projects-record-sample\scripts\InvokeSampleTest.ps1 ` + -SamplePath "" -NodeId "" -Mode "playback-only" -Run + +# live-test-locally +.\.github\skills\azure-ai-projects-record-sample\scripts\InvokeSampleTest.ps1 ` + -SamplePath "" -NodeId "" -Mode "live-test-locally" ` + -Run -ConfirmSelection -ConfirmLive +``` + +The orchestrator keeps values internal, loads only relevant `.env` names, and handles all mode flags. Immediately before recording, it stops `*testproxy*` processes and deletes `.pytest_cache`. `record-and-playback` proceeds to playback even when recording fails. After a successful hosted-agent recording it runs `tests\samples\repair_hosted_agent_sample_recording.py` before playback or completion. + +Recording modes invoke `scripts\TestRecordingGitConflicts.ps1` during preview and again immediately before cleanup. Treat that script's result as authoritative; do not reproduce its conflict checks in the skill workflow. + +## 4. Diagnose only relevant failures + +If playback fails, inspect only the selected node's mismatch and named recording. Make the smallest sanitizer or deterministic-input fix. Before another recording attempt, rerun preview and ask both recording questions again. + +Do not repair unrelated failures, edit recordings by hand, or fall back to bare pytest/VS Code playback. Never stage, commit, or push recordings unless explicitly requested. + +## 5. Validate and report + +Run `git diff --check` and `git status --short`. Report the selected mode, sample, and node; environment variable names used; required, harness-supplied, missing, and empty names; recording, repair, playback, and live results; and changed recording paths. Do not repeat environment values in the final report. Report unselected steps as `not run`. Never claim `record-and-playback` success unless playback passed. \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetEnvironmentFileVariables.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetEnvironmentFileVariables.ps1 new file mode 100644 index 000000000000..8dd0f42250ac --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetEnvironmentFileVariables.ps1 @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [string]$EnvFile, + [string]$PythonExecutable, + [string]$PackageRoot, + [switch]$IncludeValues +) + +$ErrorActionPreference = "Stop" +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path).FullName +} + +if (-not $EnvFile) { + $EnvFile = Join-Path $packageRoot ".env" +} elseif (-not [System.IO.Path]::IsPathRooted($EnvFile)) { + $EnvFile = Join-Path $packageRoot $EnvFile +} +if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { + throw "Environment file not found: $EnvFile" +} +$EnvFile = (Get-Item -LiteralPath $EnvFile).FullName + +if (-not $PythonExecutable) { + $windowsVenvPython = Join-Path $packageRoot ".venv\Scripts\python.exe" + $posixVenvPython = Join-Path $packageRoot ".venv\bin\python" + if (Test-Path -LiteralPath $windowsVenvPython) { + $PythonExecutable = $windowsVenvPython + } elseif (Test-Path -LiteralPath $posixVenvPython) { + $PythonExecutable = $posixVenvPython + } else { + $PythonExecutable = "python" + } +} + +$pythonScript = @' +from __future__ import annotations + +import json +from pathlib import Path +import sys + +from dotenv import dotenv_values +from dotenv.parser import parse_stream + + +env_path = Path(sys.argv[1]) +with env_path.open(encoding="utf-8-sig") as stream: + bindings = list(parse_stream(stream)) + +effective_values = dotenv_values(env_path, encoding="utf-8-sig") +seen: set[str] = set() +variables: list[dict[str, object]] = [] +for binding in reversed(bindings): + name = binding.key + if not name or name in seen: + continue + seen.add(name) + variables.append( + { + "Name": name, + "Value": effective_values.get(name), + "LineNumber": binding.original.line, + } + ) + +print(json.dumps(variables)) +'@ + +$output = @($pythonScript | & $PythonExecutable - $EnvFile) +if ($LASTEXITCODE -ne 0) { + throw "Unable to parse environment file '$EnvFile'." +} +$variables = @($output -join [Environment]::NewLine | ConvertFrom-Json) + +if ($IncludeValues) { + $variables +} else { + $variables | Select-Object -ExpandProperty Name +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetSampleRequiredEnvironmentVariables.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetSampleRequiredEnvironmentVariables.ps1 new file mode 100644 index 000000000000..6a7c538d7805 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/GetSampleRequiredEnvironmentVariables.ps1 @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [Alias("Sample")] + [string[]]$SamplePath, + + [string]$PackageRoot +) + +$ErrorActionPreference = "Stop" +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path).FullName +} +$samplesRoot = Join-Path $packageRoot "samples" + +function Resolve-SampleFile { + param([string]$RequestedPath) + + $candidate = $RequestedPath + if (-not [System.IO.Path]::IsPathRooted($candidate)) { + $candidate = Join-Path $packageRoot $candidate + } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Get-Item -LiteralPath $candidate).FullName + } + + $name = [System.IO.Path]::GetFileName($RequestedPath) + if (-not $name.EndsWith(".py", [System.StringComparison]::OrdinalIgnoreCase)) { + $name += ".py" + } + $sampleMatches = @(Get-ChildItem -Path $samplesRoot -Recurse -File -Filter $name) + if ($sampleMatches.Count -eq 0) { + throw "Could not find sample '$RequestedPath' under '$samplesRoot'." + } + if ($sampleMatches.Count -gt 1) { + throw "Sample '$RequestedPath' matched multiple files. Pass a path relative to the package root." + } + return $sampleMatches[0].FullName +} + +$files = if ($SamplePath) { + @($SamplePath | ForEach-Object { Resolve-SampleFile $_ }) +} else { + @(Get-ChildItem -Path $samplesRoot -Recurse -File -Filter "*.py" | ForEach-Object { $_.FullName }) +} + +$patterns = @( + 'os\.environ\s*\[\s*["''](?[A-Z][A-Z0-9_]*)["'']\s*\](?!\s*=)', + '(?:os\.environ\.get|os\.getenv)\s*\(\s*["''](?[A-Z][A-Z0-9_]*)["'']\s*\)(?!\s*or\b)', + 'get_required_env_var\s*\(\s*["''](?[A-Z][A-Z0-9_]*)["'']\s*\)' +) + +$names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($file in $files) { + $content = Get-Content -LiteralPath $file -Raw + foreach ($pattern in $patterns) { + foreach ($match in [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) { + $null = $names.Add($match.Groups["name"].Value) + } + } +} + +$names | Sort-Object \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/InvokeSampleTest.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/InvokeSampleTest.ps1 new file mode 100644 index 000000000000..a6e632ed001c --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/InvokeSampleTest.ps1 @@ -0,0 +1,356 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [Alias("Sample")] + [string]$SamplePath, + + [string]$NodeId, + + [ValidateSet("record-and-playback", "record-only", "playback-only", "live-test-locally")] + [string]$Mode, + + [string]$EnvFile, + [string]$PythonExecutable, + [string]$PackageRoot, + [ValidateSet("main", "feature/azure-ai-projects/vnext")] + [string]$BaseBranch, + [string]$RecordingConflictCheckScript, + [string]$RecordingCleanupScript, + [switch]$Run, + [switch]$ConfirmSelection, + [switch]$ConfirmRecording, + [switch]$ConfirmLive +) + +$ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($Mode)) { + throw "Mode is required. Choose record-and-playback, record-only, playback-only, or live-test-locally." +} +$scriptRoot = $PSScriptRoot +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $scriptRoot "..\..\..\..")).Path).FullName +} +if (-not $PythonExecutable) { + $windowsVenvPython = Join-Path $packageRoot ".venv\Scripts\python.exe" + $posixVenvPython = Join-Path $packageRoot ".venv\bin\python" + if (Test-Path -LiteralPath $windowsVenvPython) { + $PythonExecutable = $windowsVenvPython + } elseif (Test-Path -LiteralPath $posixVenvPython) { + $PythonExecutable = $posixVenvPython + } else { + $PythonExecutable = "python" + } +} +if (-not $EnvFile) { + $EnvFile = Join-Path $packageRoot ".env" +} + +$resolver = Join-Path $scriptRoot "ResolveSampleTest.ps1" +$requiredVariableScanner = Join-Path $scriptRoot "GetSampleRequiredEnvironmentVariables.ps1" +$environmentFileScanner = Join-Path $scriptRoot "GetEnvironmentFileVariables.ps1" +if (-not $RecordingCleanupScript) { + $RecordingCleanupScript = Join-Path $scriptRoot "ResetRecordingState.ps1" +} +if (-not $RecordingConflictCheckScript) { + $RecordingConflictCheckScript = Join-Path $scriptRoot "TestRecordingGitConflicts.ps1" +} + +$resolvedNodes = @( + & $resolver -SamplePath $SamplePath -PackageRoot $packageRoot -PythonExecutable $PythonExecutable +) +if ($NodeId) { + $normalizedRequestedNode = $NodeId.Replace("\", "/") + $resolvedNodes = @( + $resolvedNodes | Where-Object { + $candidate = $_.NodeId.Replace("\", "/") + $candidate -eq $normalizedRequestedNode -or $candidate.EndsWith($normalizedRequestedNode) + } + ) + if ($resolvedNodes.Count -eq 0) { + throw "The requested node id does not execute '$SamplePath': $NodeId" + } +} +if ($resolvedNodes.Count -gt 1) { + $choices = $resolvedNodes.NodeId -join [Environment]::NewLine + throw "Multiple pytest nodes execute this sample. Rerun with -NodeId and one of:$([Environment]::NewLine)$choices" +} +$resolved = $resolvedNodes[0] +$NodeId = $resolved.NodeId +$resolvedSamplePath = Join-Path $packageRoot $resolved.SamplePath + +$requiredNames = @( + & $requiredVariableScanner -SamplePath $resolvedSamplePath -PackageRoot $packageRoot +) + +$metadataScript = @' +from __future__ import annotations + +import ast +import json +from pathlib import Path +import re +import sys + + +root = Path(sys.argv[1]).resolve() +sample_path = Path(sys.argv[2]).resolve() +node_id = sys.argv[3].replace("\\", "/") + + +def parse(path: Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + + +def literal_keyword(call: ast.Call, name: str): + keyword = next((item for item in call.keywords if item.arg == name), None) + if keyword is None: + return None + try: + return ast.literal_eval(keyword.value) + except (TypeError, ValueError): + return None + + +tests_index = node_id.find("tests/") +if tests_index < 0: + raise RuntimeError(f"Node id has no tests/ path: {node_id}") +parts = node_id[tests_index:].split("::") +test_path = root / parts[0] +method_name = parts[-1].split("[", 1)[0] +parameter_match = re.search(r"\[(?P.*)\]$", node_id) +parameter_id = parameter_match.group("id") if parameter_match else "" +tree = parse(test_path) + +uses_llm_validation = False +for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == method_name: + uses_llm_validation = any( + isinstance(child, ast.Attribute) + and child.attr in {"validate_print_calls_by_llm", "validate_print_calls_by_llm_async"} + for child in ast.walk(node) + ) + break + +harness_names: set[str] = set() +for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)): + function_name = call.func.id if isinstance(call.func, ast.Name) else getattr(call.func, "attr", None) + if function_name != "AdditionalSampleTestDetail": + continue + filename = literal_keyword(call, "sample_filename") + test_id = literal_keyword(call, "test_id") + env_vars = literal_keyword(call, "env_vars") or {} + if not test_id: + test_id = f"{Path(filename).stem}-[{','.join(sorted(env_vars))}]" + if filename == sample_path.name and test_id == parameter_id: + harness_names.update(env_vars) + +option_map: dict[str, str] = {} +test_base_path = root / "tests" / "test_base.py" +if test_base_path.is_file(): + for node in parse(test_base_path).body: + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == "servicePreparer" for target in node.targets): + continue + if isinstance(node.value, ast.Call): + for keyword in node.value.keywords: + if keyword.arg: + option_map[keyword.arg.upper()] = keyword.arg + +print( + json.dumps( + { + "HarnessNames": sorted(harness_names), + "UsesLlmValidation": uses_llm_validation, + "OptionMap": option_map, + } + ) +) +'@ +$metadataJson = @($metadataScript | & $PythonExecutable - $packageRoot $resolvedSamplePath $NodeId) +if ($LASTEXITCODE -ne 0) { + throw "Unable to inspect the selected sample test metadata." +} +$metadata = $metadataJson -join [Environment]::NewLine | ConvertFrom-Json + +$allRequiredNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($name in $requiredNames) { + $null = $allRequiredNames.Add([string]$name) +} +if ($metadata.UsesLlmValidation) { + $null = $allRequiredNames.Add("LLM_VALIDATION_PROJECT_ENDPOINT") +} +$harnessNames = @($metadata.HarnessNames) + +$envFileVariables = if (Test-Path -LiteralPath $EnvFile -PathType Leaf) { + @( + & $environmentFileScanner -EnvFile $EnvFile -PackageRoot $packageRoot ` + -PythonExecutable $PythonExecutable -IncludeValues + ) +} else { + @() +} +$envByName = @{} +foreach ($variable in $envFileVariables) { + $envByName[$variable.Name] = $variable.Value +} + +$optionMap = @{} +foreach ($property in $metadata.OptionMap.PSObject.Properties) { + $optionMap[$property.Name] = [string]$property.Value +} +$commandLineValues = [ordered]@{} +$processEnvironmentValues = [ordered]@{} +$missingNames = [System.Collections.Generic.List[string]]::new() +$emptyNames = [System.Collections.Generic.List[string]]::new() +$usedEnvironmentNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + +foreach ($name in @($allRequiredNames | Sort-Object)) { + if (-not $envByName.ContainsKey($name)) { + $missingNames.Add($name) + continue + } + $value = $envByName[$name] + if ([string]::IsNullOrEmpty([string]$value)) { + $emptyNames.Add($name) + continue + } + if ($optionMap.ContainsKey($name)) { + $commandLineValues[$optionMap[$name]] = [string]$value + } else { + $processEnvironmentValues[$name] = [string]$value + } + $null = $usedEnvironmentNames.Add($name) +} +foreach ($name in $harnessNames) { + if (-not $envByName.ContainsKey($name) -or [string]::IsNullOrEmpty([string]$envByName[$name])) { + continue + } + if ($optionMap.ContainsKey($name)) { + $commandLineValues[$optionMap[$name]] = [string]$envByName[$name] + } else { + $processEnvironmentValues[$name] = [string]$envByName[$name] + } + $null = $usedEnvironmentNames.Add([string]$name) +} + +$steps = switch ($Mode) { + "record-and-playback" { @("record", "playback") } + "record-only" { @("record") } + "playback-only" { @("playback") } + "live-test-locally" { @("live") } +} +$flags = @{ + record = @("--azure_test_run_live=true", "--azure_skip_live_recording=false") + playback = @("--azure_test_run_live=false", "--azure_skip_live_recording=true") + live = @("--azure_test_run_live=true", "--azure_skip_live_recording=true") +} + +$conflictCheckArguments = $null +$recordingConflictResult = $null +if ($steps -contains "record") { + $conflictCheckArguments = @{ PackageRoot = $packageRoot } + if ($BaseBranch) { + $conflictCheckArguments.BaseBranch = $BaseBranch + } + $recordingConflictResult = & $RecordingConflictCheckScript @conflictCheckArguments +} + +Write-Output "Mode: $Mode" +Write-Output "Sample: $($resolved.SamplePath)" +Write-Output "Node: $NodeId" +Write-Output "Required: $((@($allRequiredNames | Sort-Object) -join ', '))" +Write-Output "Harness-supplied: $($harnessNames -join ', ')" +Write-Output "Missing: $($missingNames -join ', ')" +Write-Output "Empty: $($emptyNames -join ', ')" +if ($usedEnvironmentNames.Count -eq 0) { + Write-Output "Environment variables used: " +} else { + Write-Output "Environment variables used:" + foreach ($name in @($usedEnvironmentNames | Sort-Object)) { + Write-Output " $name=$($envByName[$name])" + } +} +Write-Output "Steps: $($steps -join ', ')" +if ($recordingConflictResult) { + Write-Output "Recording conflict check: safe ($($recordingConflictResult.AssetRelationship))" +} +Write-Output "CLI values loaded: $($commandLineValues.Count); process values loaded: $($processEnvironmentValues.Count)" + +if (-not $Run) { + foreach ($step in $steps) { + $redactedOptions = @($commandLineValues.Keys | ForEach-Object { "--$_=" }) + Write-Output "$step command: pytest -s $($redactedOptions -join ' ') $($flags[$step] -join ' ')" + } + return +} + +if (($steps -contains "record" -or $steps -contains "live") -and -not $ConfirmSelection) { + throw "Execution selection was not confirmed. Review the sample, node, and environment variable names, then rerun with -Run -ConfirmSelection." +} +if ($steps -contains "record" -and -not $ConfirmRecording) { + throw "Recording may modify files. Rerun with -Run -ConfirmRecording after user approval." +} +if ($steps -contains "live" -and -not $ConfirmLive) { + throw "Live execution contacts Azure. Rerun with -Run -ConfirmLive after user approval." +} + +$originalEnvironment = @{} +foreach ($entry in $processEnvironmentValues.GetEnumerator()) { + $originalEnvironment[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, "Process") + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") +} + +$results = [ordered]@{} +try { + foreach ($step in $steps) { + if ($step -eq "record") { + $recordingConflictResult = & $RecordingConflictCheckScript @conflictCheckArguments + Write-Output "recording conflict recheck: safe ($($recordingConflictResult.AssetRelationship))" + & $RecordingCleanupScript -PackageRoot $packageRoot + Write-Output "recording cleanup: completed" + } + $pytestArguments = @("-m", "pytest", $NodeId, "-s") + foreach ($entry in $commandLineValues.GetEnumerator()) { + $pytestArguments += "--$($entry.Key)=$($entry.Value)" + } + $pytestArguments += $flags[$step] + Push-Location $packageRoot + try { + & $PythonExecutable @pytestArguments + $results[$step] = $LASTEXITCODE + } finally { + Pop-Location + } + + if ($step -eq "record" -and $resolved.SamplePath.Replace("/", "\").StartsWith("samples\hosted_agents\")) { + $repairScript = Join-Path $packageRoot "tests\samples\repair_hosted_agent_sample_recording.py" + if ($results[$step] -eq 0 -and (Test-Path -LiteralPath $repairScript)) { + & $PythonExecutable $repairScript $NodeId + $results["repair"] = $LASTEXITCODE + } else { + $results["repair"] = "not run" + } + } + } +} finally { + foreach ($entry in $originalEnvironment.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } +} + +foreach ($entry in $results.GetEnumerator()) { + Write-Output "$($entry.Key) result: $($entry.Value)" +} +$failedResults = @( + $results.GetEnumerator() | Where-Object { $_.Value -is [int] -and $_.Value -ne 0 } +) +if ($failedResults.Count -gt 0) { + $failureSummary = @($failedResults | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ", " + throw "Sample test workflow failed: $failureSummary" +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResetRecordingState.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResetRecordingState.ps1 new file mode 100644 index 000000000000..efa81a831820 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResetRecordingState.ps1 @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [string]$PackageRoot, + [scriptblock]$ProcessProvider = { Get-Process }, + [scriptblock]$ProcessStopper = { param($Processes) $Processes | Stop-Process -Force } +) + +$ErrorActionPreference = "Stop" +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path).FullName +} + +$testProxyProcesses = @( + & $ProcessProvider | Where-Object { $_.ProcessName -like "*testproxy*" } +) +if ($testProxyProcesses.Count -gt 0) { + & $ProcessStopper $testProxyProcesses +} + +Remove-Item -Recurse -Force (Join-Path $packageRoot ".pytest_cache") -ErrorAction SilentlyContinue \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResolveSampleTest.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResolveSampleTest.ps1 new file mode 100644 index 000000000000..8d06b9945f01 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/ResolveSampleTest.ps1 @@ -0,0 +1,154 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [Alias("Sample")] + [string]$SamplePath, + + [string]$PythonExecutable, + [string]$PackageRoot +) + +$ErrorActionPreference = "Stop" +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path).FullName +} +$samplesRoot = Join-Path $packageRoot "samples" + +if (-not $PythonExecutable) { + $windowsVenvPython = Join-Path $packageRoot ".venv\Scripts\python.exe" + $posixVenvPython = Join-Path $packageRoot ".venv\bin\python" + if (Test-Path -LiteralPath $windowsVenvPython) { + $PythonExecutable = $windowsVenvPython + } elseif (Test-Path -LiteralPath $posixVenvPython) { + $PythonExecutable = $posixVenvPython + } else { + $PythonExecutable = "python" + } +} + +function Resolve-SampleFile { + param([string]$RequestedPath) + + $candidate = $RequestedPath + if (-not [System.IO.Path]::IsPathRooted($candidate)) { + $candidate = Join-Path $packageRoot $candidate + } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Get-Item -LiteralPath $candidate).FullName + } + + $name = [System.IO.Path]::GetFileName($RequestedPath) + if (-not $name.EndsWith(".py", [System.StringComparison]::OrdinalIgnoreCase)) { + $name += ".py" + } + $sampleMatches = @(Get-ChildItem -Path $samplesRoot -Recurse -File -Filter $name) + if ($sampleMatches.Count -eq 0) { + throw "Could not find sample '$RequestedPath' under '$samplesRoot'." + } + if ($sampleMatches.Count -gt 1) { + throw "Sample '$RequestedPath' matched multiple files. Pass a path relative to the package root." + } + return $sampleMatches[0].FullName +} + +$resolvedSample = Resolve-SampleFile $SamplePath +$testFiles = @( + Get-ChildItem -Path (Join-Path $packageRoot "tests\samples") -File -Filter "test_samples*.py" | + Where-Object { -not $_.Name.EndsWith("_helpers.py") } | + ForEach-Object { $_.FullName } +) + +$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("resolve-sample-test-{0}" -f [guid]::NewGuid()) +$pluginPath = Join-Path $temporaryDirectory "sample_node_collector.py" +$outputPath = Join-Path $temporaryDirectory "sample-nodes.json" +$pluginSource = @' +import json +import os +from pathlib import Path + + +def pytest_collection_finish(session): + records = [] + for item in session.items: + callspec = getattr(item, "callspec", None) + if callspec is None: + continue + sample_path = callspec.params.get("sample_path") + if sample_path is None: + continue + records.append( + { + "samplePath": str(Path(str(sample_path)).resolve()), + "nodeId": item.nodeid, + } + ) + Path(os.environ["AZURE_AI_PROJECTS_SAMPLE_NODE_OUTPUT"]).write_text( + json.dumps(records), encoding="utf-8" + ) +'@ + +New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null +[System.IO.File]::WriteAllText($pluginPath, $pluginSource, [System.Text.UTF8Encoding]::new($false)) + +$oldPythonPath = $env:PYTHONPATH +$oldOutputPath = $env:AZURE_AI_PROJECTS_SAMPLE_NODE_OUTPUT +try { + $env:PYTHONPATH = if ($oldPythonPath) { "$temporaryDirectory$([System.IO.Path]::PathSeparator)$oldPythonPath" } else { $temporaryDirectory } + $env:AZURE_AI_PROJECTS_SAMPLE_NODE_OUTPUT = $outputPath + Push-Location $packageRoot + try { + $collectionOutput = @( + & $PythonExecutable -m pytest --collect-only -q -p sample_node_collector @testFiles ` + --azure_test_run_live=false --azure_skip_live_recording=true 2>&1 + ) + if ($LASTEXITCODE -ne 0) { + throw "pytest collection failed:`n$($collectionOutput -join [Environment]::NewLine)" + } + } finally { + Pop-Location + } + + $records = @(Get-Content -LiteralPath $outputPath -Raw | ConvertFrom-Json) +} finally { + $env:PYTHONPATH = $oldPythonPath + $env:AZURE_AI_PROJECTS_SAMPLE_NODE_OUTPUT = $oldOutputPath + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +$pathComparison = if ($env:OS -eq "Windows_NT") { + [System.StringComparison]::OrdinalIgnoreCase +} else { + [System.StringComparison]::Ordinal +} +$matchingNodes = @( + $records | + Where-Object { [string]::Equals($_.samplePath, $resolvedSample, $pathComparison) } | + Select-Object -ExpandProperty nodeId -Unique | + ForEach-Object { + $normalizedNodeId = $_.Replace("\", "/") + $testsIndex = $normalizedNodeId.IndexOf("tests/") + if ($testsIndex -ge 0) { + $normalizedNodeId.Substring($testsIndex) + } else { + $normalizedNodeId + } + } | + Sort-Object +) + +if ($matchingNodes.Count -eq 0) { + throw "No collected sample test executes '$resolvedSample'. The sample may be excluded from the recorded sample harness." +} + +$relativeSample = [System.IO.Path]::GetRelativePath($packageRoot, $resolvedSample).Replace("/", "\") +foreach ($nodeId in $matchingNodes) { + [pscustomobject]@{ + SamplePath = $relativeSample + NodeId = $nodeId + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/TestRecordingGitConflicts.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/TestRecordingGitConflicts.ps1 new file mode 100644 index 000000000000..df7ba29fde01 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/scripts/TestRecordingGitConflicts.ps1 @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [string]$PackageRoot, + [ValidateSet("main", "feature/azure-ai-projects/vnext")] + [string]$BaseBranch, + [string]$AssetsRepositoryPath, + [switch]$SkipFetch +) + +$ErrorActionPreference = "Stop" +if ($PackageRoot) { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path -LiteralPath $PackageRoot).Path).FullName +} else { + $packageRoot = (Get-Item -LiteralPath (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path).FullName +} + +function Invoke-Git { + param( + [string]$WorkingDirectory, + [string[]]$Arguments, + [switch]$AllowFailure + ) + + $output = @(& git -C $WorkingDirectory @Arguments 2>&1) + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "git $($Arguments -join ' ') failed:`n$($output -join [Environment]::NewLine)" + } + [pscustomobject]@{ + ExitCode = $exitCode + Output = ($output -join [Environment]::NewLine).Trim() + } +} + +$repositoryRoot = (Invoke-Git $packageRoot @("rev-parse", "--show-toplevel")).Output +$repositoryRoot = (Get-Item -LiteralPath $repositoryRoot).FullName +$currentBranch = (Invoke-Git $repositoryRoot @("branch", "--show-current")).Output +if (-not $currentBranch) { + throw "Recording is blocked from a detached HEAD. Switch to a branch and rebase the target first." +} + +if (-not $BaseBranch) { + if ($currentBranch -in @("main", "feature/azure-ai-projects/vnext")) { + $BaseBranch = $currentBranch + } else { + throw "BaseBranch is required for topic branch '$currentBranch'. Choose main or feature/azure-ai-projects/vnext." + } +} + +$remoteExists = (Invoke-Git $repositoryRoot @("remote", "get-url", "origin") -AllowFailure).ExitCode -eq 0 +if ($remoteExists -and -not $SkipFetch) { + $refspec = "+refs/heads/$BaseBranch`:refs/remotes/origin/$BaseBranch" + $fetch = Invoke-Git $repositoryRoot @("fetch", "--quiet", "origin", $refspec) -AllowFailure + if ($fetch.ExitCode -ne 0) { + throw "Unable to refresh origin/$BaseBranch. Recording is blocked because base freshness cannot be verified." + } +} + +$remoteBaseRef = "refs/remotes/origin/$BaseBranch" +$localBaseRef = "refs/heads/$BaseBranch" +if ((Invoke-Git $repositoryRoot @("show-ref", "--verify", "--quiet", $remoteBaseRef) -AllowFailure).ExitCode -eq 0) { + $baseRef = "origin/$BaseBranch" +} elseif ((Invoke-Git $repositoryRoot @("show-ref", "--verify", "--quiet", $localBaseRef) -AllowFailure).ExitCode -eq 0) { + $baseRef = $BaseBranch +} else { + throw "Base branch '$BaseBranch' was not found locally or under origin." +} + +$sourceAncestor = Invoke-Git $repositoryRoot @("merge-base", "--is-ancestor", $baseRef, "HEAD") -AllowFailure +if ($sourceAncestor.ExitCode -ne 0) { + throw "Recording is blocked: HEAD does not contain the latest '$baseRef'. Rebase the target branch, then retry." +} + +$relativePackagePath = [System.IO.Path]::GetRelativePath($repositoryRoot, $packageRoot).Replace("\", "/") +$assetsRelativePath = "$relativePackagePath/assets.json" +$localAssetsPath = Join-Path $packageRoot "assets.json" +if (-not (Test-Path -LiteralPath $localAssetsPath -PathType Leaf)) { + throw "Recording is blocked: local assets.json was not found at '$localAssetsPath'." +} + +try { + $localAssets = Get-Content -LiteralPath $localAssetsPath -Raw | ConvertFrom-Json + $baseAssetsText = (Invoke-Git $repositoryRoot @("show", "$baseRef`:$assetsRelativePath")).Output + $baseAssets = $baseAssetsText | ConvertFrom-Json +} catch { + throw "Recording is blocked: unable to parse local or base assets.json. $($_.Exception.Message)" +} + +$localTag = [string]$localAssets.Tag +$baseTag = [string]$baseAssets.Tag +if (-not $localTag -or -not $baseTag) { + throw "Recording is blocked: local or base assets.json does not contain a Tag." +} + +$assetRelationship = "same" +$baseAssetCommit = $null +$localAssetCommit = $null +$baseAssetCommitDate = $null +$localAssetCommitDate = $null +if ($localTag -ne $baseTag) { + if (-not $AssetsRepositoryPath) { + $breadcrumbRoot = Join-Path $repositoryRoot ".assets\breadcrumb" + if (Test-Path -LiteralPath $breadcrumbRoot -PathType Container) { + $breadcrumb = Get-ChildItem -LiteralPath $breadcrumbRoot -File -Filter "*.breadcrumb" | + Where-Object { (Get-Content -LiteralPath $_.FullName -Raw).StartsWith("$assetsRelativePath;") } | + Select-Object -First 1 + if ($breadcrumb) { + $parts = (Get-Content -LiteralPath $breadcrumb.FullName -Raw).Trim().Split(";") + if ($parts.Count -ge 2) { + $AssetsRepositoryPath = Join-Path (Join-Path $repositoryRoot ".assets") $parts[1] + } + } + } + } + if (-not $AssetsRepositoryPath -or -not (Test-Path -LiteralPath (Join-Path $AssetsRepositoryPath ".git"))) { + throw "Recording is blocked: local assets tag differs from '$baseRef', but the assets Git cache is unavailable for ancestry validation." + } + + $baseAssetCommitResult = Invoke-Git $AssetsRepositoryPath @("rev-parse", "$baseTag`^{commit}") -AllowFailure + $localAssetCommitResult = Invoke-Git $AssetsRepositoryPath @("rev-parse", "$localTag`^{commit}") -AllowFailure + if ($baseAssetCommitResult.ExitCode -ne 0 -or $localAssetCommitResult.ExitCode -ne 0) { + throw "Recording is blocked: base or local asset tag is unavailable in the assets Git cache." + } + $baseAssetCommit = $baseAssetCommitResult.Output + $localAssetCommit = $localAssetCommitResult.Output + $assetAncestor = Invoke-Git $AssetsRepositoryPath @( + "merge-base", "--is-ancestor", $baseAssetCommit, $localAssetCommit + ) -AllowFailure + if ($assetAncestor.ExitCode -ne 0) { + throw "Recording is blocked: local asset tag '$localTag' is not descended from base tag '$baseTag'. Rebase the target branch before recording." + } + $assetRelationship = "descendant" + $baseAssetCommitDate = (Invoke-Git $AssetsRepositoryPath @("show", "-s", "--format=%cI", $baseAssetCommit)).Output + $localAssetCommitDate = (Invoke-Git $AssetsRepositoryPath @("show", "-s", "--format=%cI", $localAssetCommit)).Output +} + +[pscustomobject]@{ + CurrentBranch = $currentBranch + BaseBranch = $BaseBranch + BaseRef = $baseRef + LocalTag = $localTag + BaseTag = $baseTag + AssetRelationship = $assetRelationship + BaseAssetCommit = $baseAssetCommit + LocalAssetCommit = $localAssetCommit + BaseAssetCommitDate = $baseAssetCommitDate + LocalAssetCommitDate = $localAssetCommitDate + SafeToRecord = $true +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetEnvironmentFileVariables.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetEnvironmentFileVariables.Tests.ps1 new file mode 100644 index 000000000000..e576179e5ad3 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetEnvironmentFileVariables.Tests.ps1 @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "GetEnvironmentFileVariables.ps1" { + BeforeAll { + $script:envFile = Join-Path $TestDrive ".env" + @' +# ignored comment +FIRST=old-value +EMPTY= +export EXPORTED="quoted value" +MULTILINE="first line +second line" +FIRST=effective-value +BARE +'@ | Set-Content -LiteralPath $envFile -Encoding UTF8 + } + + It "returns names in bottom-to-top effective assignment order" { + $actual = @(& $paths.EnvironmentFileScanner -EnvFile $envFile -PythonExecutable $paths.PythonExecutable) + ($actual -join ",") | Should Be "BARE,FIRST,MULTILINE,EXPORTED,EMPTY" + } + + It "returns last duplicate values and source lines" { + $actual = @( + & $paths.EnvironmentFileScanner -EnvFile $envFile ` + -PythonExecutable $paths.PythonExecutable -IncludeValues + ) + $first = @($actual | Where-Object { $_.Name -eq "FIRST" }) + $multiline = @($actual | Where-Object { $_.Name -eq "MULTILINE" }) + $first.Count | Should Be 1 + $first[0].Value | Should Be "effective-value" + $first[0].LineNumber | Should Be 7 + $multiline[0].Value | Should Be ("first line", "second line" -join "`n") + } + + It "uses the package-root .env by default" { + $packageRoot = Join-Path $TestDrive "default-env-package" + New-Item -ItemType Directory -Path $packageRoot | Out-Null + "DEFAULT_NAME=default-value" | Set-Content -LiteralPath (Join-Path $packageRoot ".env") -Encoding UTF8 + $actual = @( + & $paths.EnvironmentFileScanner -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable + ) + ($actual -join ",") | Should Be "DEFAULT_NAME" + } + + It "returns no names for an empty environment file" { + $emptyEnv = Join-Path $TestDrive "empty.env" + "# no assignments" | Set-Content -LiteralPath $emptyEnv -Encoding UTF8 + $actual = @(& $paths.EnvironmentFileScanner -EnvFile $emptyEnv -PythonExecutable $paths.PythonExecutable) + $actual.Count | Should Be 0 + } + + It "rejects a missing environment file" { + $caught = $null + try { + & $paths.EnvironmentFileScanner -EnvFile (Join-Path $TestDrive "missing.env") ` + -PythonExecutable $paths.PythonExecutable + } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } + + It "reports a Python execution failure" { + $caught = $null + try { + & $paths.EnvironmentFileScanner -EnvFile $envFile ` + -PythonExecutable (Join-Path $TestDrive "missing-python.exe") + } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetSampleRequiredEnvironmentVariables.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetSampleRequiredEnvironmentVariables.Tests.ps1 new file mode 100644 index 000000000000..6a5098b5572f --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/GetSampleRequiredEnvironmentVariables.Tests.ps1 @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "GetSampleRequiredEnvironmentVariables.ps1" { + BeforeAll { + $script:packageRoot = Join-Path $TestDrive "scanner-package" + New-SyntheticPackage $packageRoot + } + + It "returns the required variables for a selected sample" { + $actual = @(& $paths.EnvironmentScanner -PackageRoot $packageRoot -SamplePath "sample_unique.py") + ($actual -join ",") | Should Be "DIRECT_REQUIRED,HELPER_REQUIRED" + } + + It "excludes writes, defaults, and fallbacks" { + $actual = @(& $paths.EnvironmentScanner -PackageRoot $packageRoot -SamplePath "sample_unique.py") + ($actual -join ",") | Should Not Match "ASSIGNED_ONLY|OPTIONAL_WITH_DEFAULT|OPTIONAL_WITH_FALLBACK" + } + + It "returns sorted unique names for the full sample tree" { + $actual = @(& $paths.EnvironmentScanner -PackageRoot $packageRoot) + $sortedUnique = @($actual | Sort-Object -Unique) + $actual.Count | Should Be 6 + ($actual -join "`n") | Should Be ($sortedUnique -join "`n") + } + + It "merges multiple selected samples" { + $actual = @( + & $paths.EnvironmentScanner -PackageRoot $packageRoot ` + -SamplePath "sample_unique", "sample_async.py" + ) + ($actual -join ",") | Should Be "ASYNC_REQUIRED,DIRECT_REQUIRED,HELPER_REQUIRED" + } + + It "rejects an ambiguous filename" { + $caught = $null + try { & $paths.EnvironmentScanner -PackageRoot $packageRoot -SamplePath "util.py" } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } + + It "rejects a missing sample" { + $caught = $null + try { & $paths.EnvironmentScanner -PackageRoot $packageRoot -SamplePath "missing.py" } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Integration.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Integration.Tests.ps1 new file mode 100644 index 000000000000..317a3d89ebf0 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Integration.Tests.ps1 @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "InvokeSampleTest.ps1 integration" { + BeforeAll { + $script:packageRoot = Join-Path $TestDrive "runner-integration-package" + New-SyntheticPackage $packageRoot + $script:conflictCheckScript = Join-Path $packageRoot "synthetic-recording-conflict-check.ps1" + $script:cleanupScript = Join-Path $packageRoot "synthetic-recording-cleanup.ps1" + } + + It "executes playback without live approval" { + $actual = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "playback-only" -Run) + ($actual -join "`n") | Should Match "playback result: 0" + } + + It "executes record-only and live modes with approval" { + $cachePath = Join-Path $packageRoot ".pytest_cache" + New-Item -ItemType Directory -Path $cachePath -Force | Out-Null + $record = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -RecordingCleanupScript $cleanupScript -SamplePath "sample_unique.py" -Mode "record-only" -Run -ConfirmSelection -ConfirmRecording) + Remove-Item -LiteralPath (Join-Path $packageRoot "recording-conflict-check-called.txt") -Force + $live = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "live-test-locally" -Run -ConfirmSelection -ConfirmLive) + ($record -join "`n") | Should Match "record result: 0" + ($live -join "`n") | Should Match "live result: 0" + (Test-Path -LiteralPath (Join-Path $packageRoot "recording-conflict-check-called.txt")) | Should Be $false + } + + It "executes record then playback in order" { + $actual = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -RecordingCleanupScript $cleanupScript -SamplePath "sample_unique.py" -Mode "record-and-playback" -Run -ConfirmSelection -ConfirmRecording) + $text = $actual -join "`n" + $text.IndexOf("record result: 0") | Should BeLessThan $text.IndexOf("playback result: 0") + } + + It "continues to playback when recording fails" { + $marker = Join-Path $packageRoot "playback-after-record-failure.txt" + $caught = $null + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -RecordingCleanupScript $cleanupScript -SamplePath "sample_record_failure.py" -Mode "record-and-playback" -Run -ConfirmSelection -ConfirmRecording } catch { $caught = $_ } + $caught.Exception.Message | Should Match "record=1" + (Test-Path -LiteralPath $marker) | Should Be $true + } + + It "propagates playback failures and restores process environment" { + $original = $env:FAILURE_PROCESS_VALUE + $env:FAILURE_PROCESS_VALUE = "before-run" + $caught = $null + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_playback_failure.py" -Mode "playback-only" -Run } catch { $caught = $_ } finally { $restored = $env:FAILURE_PROCESS_VALUE; $env:FAILURE_PROCESS_VALUE = $original } + $caught.Exception.Message | Should Match "playback=1" + $restored | Should Be "before-run" + } + + It "repairs hosted-agent recordings before playback" { + $marker = Join-Path $packageRoot "tests\samples\repair-called.txt" + $actual = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -RecordingCleanupScript $cleanupScript -SamplePath "sample_hosted.py" -Mode "record-and-playback" -Run -ConfirmSelection -ConfirmRecording) + (Test-Path -LiteralPath $marker) | Should Be $true + ($actual -join "`n") | Should Match "repair result: 0" + ($actual -join "`n") | Should Match "playback result: 0" + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Tests.ps1 new file mode 100644 index 000000000000..315169a3865f --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/InvokeSampleTest.Tests.ps1 @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "InvokeSampleTest.ps1 plan and guards" { + BeforeAll { + $script:packageRoot = Join-Path $TestDrive "runner-unit-package" + New-SyntheticPackage $packageRoot + $script:conflictCheckScript = Join-Path $packageRoot "synthetic-recording-conflict-check.ps1" + $script:recordAndPlaybackPlan = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -SamplePath "sample_unique.py" -Mode "record-and-playback") + $script:recordOnlyPlan = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -SamplePath "sample_unique.py" -Mode "record-only") + $script:playbackOnlyPlan = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "playback-only") + $script:livePlan = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "live-test-locally") + } + + It "plans record followed by playback when explicitly selected" { + ($recordAndPlaybackPlan -join "`n") | Should Match "Mode: record-and-playback" + ($recordAndPlaybackPlan -join "`n") | Should Match "Steps: record, playback" + } + + It "rejects an omitted mode" { + $caught = $null + try { + & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable ` + -SamplePath "sample_unique.py" + } catch { $caught = $_ } + $caught.Exception.Message | Should Match "Mode is required" + } + + It "rejects the retired live-without-recording mode name" { + $caught = $null + try { + & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable ` + -SamplePath "sample_unique.py" -Mode "live-without-recording" + } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } + + It "maps explicit modes to exact flags" { + ($recordOnlyPlan -join "`n") | Should Match "--azure_test_run_live=true --azure_skip_live_recording=false" + ($playbackOnlyPlan -join "`n") | Should Match "--azure_test_run_live=false --azure_skip_live_recording=true" + ($livePlan -join "`n") | Should Match "--azure_test_run_live=true --azure_skip_live_recording=true" + } + + It "displays effective values while redacting command arguments" { + $text = $recordAndPlaybackPlan -join "`n" + $text | Should Match "Harness-supplied: HARNESS_VALUE" + $text | Should Match "Required: DIRECT_REQUIRED, HELPER_REQUIRED, LLM_VALIDATION_PROJECT_ENDPOINT" + $text | Should Match "DIRECT_REQUIRED=real-direct-value" + $text | Should Match "HARNESS_VALUE=real-harness-value" + $text | Should Match "HELPER_REQUIRED=real-helper-value" + $text | Should Match "LLM_VALIDATION_PROJECT_ENDPOINT=https://validation.example.test/api/projects/example" + $text | Should Match "CLI values loaded: 2; process values loaded: 2" + $commandPreview = @($recordAndPlaybackPlan | Where-Object { $_ -like "* command: *" }) -join "`n" + $commandPreview | Should Not Match "real-direct-value|real-helper-value|real-harness-value" + $commandPreview | Should Match "--direct_required=" + } + + It "requires selection and action confirmations for recording and live execution" { + $recordSelectionError = $null + $liveSelectionError = $null + $recordError = $null + $liveError = $null + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -SamplePath "sample_unique.py" -Mode "record-only" -Run } catch { $recordSelectionError = $_ } + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "live-test-locally" -Run } catch { $liveSelectionError = $_ } + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -RecordingConflictCheckScript $conflictCheckScript -SamplePath "sample_unique.py" -Mode "record-only" -Run -ConfirmSelection } catch { $recordError = $_ } + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" -Mode "live-test-locally" -Run -ConfirmSelection } catch { $liveError = $_ } + $recordSelectionError.Exception.Message | Should Match "selection was not confirmed" + $liveSelectionError.Exception.Message | Should Match "selection was not confirmed" + $recordError.Exception.Message | Should Match "Recording may modify files" + $liveError.Exception.Message | Should Match "Live execution contacts Azure" + } + + It "selects a valid explicit node and rejects missing selection" { + $actual = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_multiple.py" -NodeId "tests/samples/test_samples.py::test_multiple[variant-one]" -Mode "playback-only") + ($actual -join "`n") | Should Match "variant-one" + $caught = $null + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_multiple.py" -Mode "playback-only" } catch { $caught = $_ } + $caught.Exception.Message | Should Match "Multiple pytest nodes" + } + + It "rejects an invalid explicit node" { + $caught = $null + try { & $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "sample_multiple.py" -NodeId "tests/samples/test_samples.py::missing[node]" -Mode "playback-only" } catch { $caught = $_ } + $caught.Exception.Message | Should Match "requested node id does not execute" + } + + It "reports missing and empty required values" { + $incompleteEnv = Join-Path $TestDrive "incomplete.env" + "DIRECT_REQUIRED=" | Set-Content -LiteralPath $incompleteEnv -Encoding UTF8 + $actual = @(& $paths.SampleTestRunner -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -EnvFile $incompleteEnv -SamplePath "sample_unique.py" -Mode "playback-only") + $text = $actual -join "`n" + $text | Should Match "Missing: HELPER_REQUIRED, LLM_VALIDATION_PROJECT_ENDPOINT" + $text | Should Match "Empty: DIRECT_REQUIRED" + $text | Should Match "Environment variables used: " + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResetRecordingState.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResetRecordingState.Tests.ps1 new file mode 100644 index 000000000000..0614af7d5b74 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResetRecordingState.Tests.ps1 @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "ResetRecordingState.ps1" { + It "stops only test proxy processes and deletes pytest cache" { + $packageRoot = Join-Path $TestDrive "cleanup-package" + $cachePath = Join-Path $packageRoot ".pytest_cache" + New-Item -ItemType Directory -Path $cachePath -Force | Out-Null + "cache" | Set-Content -LiteralPath (Join-Path $cachePath "marker.txt") -Encoding UTF8 + + $stoppedNames = [System.Collections.Generic.List[string]]::new() + $provider = { + @( + [pscustomobject]@{ ProcessName = "testproxy" }, + [pscustomobject]@{ ProcessName = "prefix-testproxy-child" }, + [pscustomobject]@{ ProcessName = "python" } + ) + }.GetNewClosure() + $stopper = { + param($Processes) + foreach ($process in $Processes) { + $stoppedNames.Add($process.ProcessName) + } + }.GetNewClosure() + + & $paths.RecordingStateReset -PackageRoot $packageRoot ` + -ProcessProvider $provider -ProcessStopper $stopper + + ($stoppedNames -join ",") | Should Be "testproxy,prefix-testproxy-child" + (Test-Path -LiteralPath $cachePath) | Should Be $false + } + + It "succeeds when there are no matching processes or cache" { + $packageRoot = Join-Path $TestDrive "empty-cleanup-package" + New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null + $provider = { @([pscustomobject]@{ ProcessName = "python" }) }.GetNewClosure() + $stopper = { param($Processes) throw "Process stopper should not be called." } + + & $paths.RecordingStateReset -PackageRoot $packageRoot ` + -ProcessProvider $provider -ProcessStopper $stopper + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResolveSampleTest.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResolveSampleTest.Tests.ps1 new file mode 100644 index 000000000000..c2cd3c6e0751 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/ResolveSampleTest.Tests.ps1 @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +Describe "ResolveSampleTest.ps1" { + BeforeAll { + $script:packageRoot = Join-Path $TestDrive "resolver-package" + New-SyntheticPackage $packageRoot + $script:uniqueResult = @( + & $paths.SampleResolver -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" + ) + $script:multipleResults = @( + & $paths.SampleResolver -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_multiple.py" + ) + $script:asyncResult = @( + & $paths.SampleResolver -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_async.py" + ) + $script:evaluationResult = @( + & $paths.SampleResolver -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_evaluation.py" + ) + } + + It "resolves a filename to one synchronous pytest node" { + $uniqueResult.Count | Should Be 1 + $uniqueResult[0].SamplePath | Should Be "samples\unique\sample_unique.py" + $uniqueResult[0].NodeId | Should Match "test_samples.py::test_unique\[unique-case\]$" + } + + It "returns every collected node for a sample" { + $multipleResults.Count | Should Be 2 + ($multipleResults.NodeId -join "`n") | Should Match "variant-one\]" + ($multipleResults.NodeId -join "`n") | Should Match "variant-two\]" + } + + It "resolves async and evaluation harnesses" { + $asyncResult[0].NodeId | Should Match "test_samples_async.py::test_async\[async-case\]$" + $evaluationResult[0].NodeId | Should Match "test_samples_evaluations.py::test_evaluation\[evaluation-case\]$" + } + + It "rejects ambiguous and missing filenames" { + $ambiguous = $null + $missing = $null + try { & $paths.SampleResolver -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "util.py" } catch { $ambiguous = $_ } + try { & $paths.SampleResolver -PackageRoot $packageRoot -PythonExecutable $paths.PythonExecutable -SamplePath "missing.py" } catch { $missing = $_ } + $ambiguous | Should Not BeNullOrEmpty + $missing | Should Not BeNullOrEmpty + } + + It "rejects a sample absent from the pytest harness" { + $caught = $null + try { + & $paths.SampleResolver -PackageRoot $packageRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_uncollected.py" + } catch { $caught = $_ } + $caught | Should Not BeNullOrEmpty + } + + It "reports pytest collection failures" { + $brokenRoot = Join-Path $TestDrive "broken-resolver-package" + New-SyntheticPackage $brokenRoot + "this is not valid python !!!" | Set-Content -LiteralPath (Join-Path $brokenRoot "tests\samples\test_samples.py") -Encoding UTF8 + $caught = $null + try { + & $paths.SampleResolver -PackageRoot $brokenRoot ` + -PythonExecutable $paths.PythonExecutable -SamplePath "sample_unique.py" + } catch { $caught = $_ } + $caught.Exception.Message | Should Match "pytest collection failed" + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestHelpers.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestHelpers.ps1 new file mode 100644 index 000000000000..3d1998419f3f --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestHelpers.ps1 @@ -0,0 +1,214 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Get-RecordSampleTestPaths { + param([string]$TestScriptRoot) + + $skillRoot = (Resolve-Path (Join-Path $TestScriptRoot "..")).Path + $repositoryPackageRoot = (Resolve-Path (Join-Path $TestScriptRoot "..\..\..\..")).Path + [pscustomobject]@{ + EnvironmentScanner = Join-Path $skillRoot "scripts\GetSampleRequiredEnvironmentVariables.ps1" + EnvironmentFileScanner = Join-Path $skillRoot "scripts\GetEnvironmentFileVariables.ps1" + SampleResolver = Join-Path $skillRoot "scripts\ResolveSampleTest.ps1" + SampleTestRunner = Join-Path $skillRoot "scripts\InvokeSampleTest.ps1" + RecordingConflictCheck = Join-Path $skillRoot "scripts\TestRecordingGitConflicts.ps1" + RecordingStateReset = Join-Path $skillRoot "scripts\ResetRecordingState.ps1" + PythonExecutable = Join-Path $repositoryPackageRoot ".venv\Scripts\python.exe" + } +} + +function New-SyntheticPackage { + param([string]$Root) + + $directories = @( + "samples\unique", + "samples\multiple", + "samples\async", + "samples\evaluations", + "samples\failures", + "samples\first", + "samples\hosted_agents", + "samples\second", + "samples\uncollected", + "tests\samples" + ) + foreach ($directory in $directories) { + New-Item -ItemType Directory -Path (Join-Path $Root $directory) -Force | Out-Null + } + + @' +import os + +direct = os.environ["DIRECT_REQUIRED"] +helper = get_required_env_var("HELPER_REQUIRED") +os.environ["ASSIGNED_ONLY"] = "value" +optional = os.environ.get("OPTIONAL_WITH_DEFAULT", "default") +fallback = os.getenv("OPTIONAL_WITH_FALLBACK") or "default" +'@ | Set-Content -LiteralPath (Join-Path $Root "samples\unique\sample_unique.py") -Encoding UTF8 + + 'import os; value = os.environ["MULTIPLE_REQUIRED"]' | + Set-Content -LiteralPath (Join-Path $Root "samples\multiple\sample_multiple.py") -Encoding UTF8 + 'import os; value = os.environ["ASYNC_REQUIRED"]' | + Set-Content -LiteralPath (Join-Path $Root "samples\async\sample_async.py") -Encoding UTF8 + 'import os; value = os.environ["EVALUATION_REQUIRED"]' | + Set-Content -LiteralPath (Join-Path $Root "samples\evaluations\sample_evaluation.py") -Encoding UTF8 + '# synthetic record failure sample' | + Set-Content -LiteralPath (Join-Path $Root "samples\failures\sample_record_failure.py") -Encoding UTF8 + 'import os; value = os.environ["FAILURE_PROCESS_VALUE"]' | + Set-Content -LiteralPath (Join-Path $Root "samples\failures\sample_playback_failure.py") -Encoding UTF8 + '# synthetic hosted-agent sample' | + Set-Content -LiteralPath (Join-Path $Root "samples\hosted_agents\sample_hosted.py") -Encoding UTF8 + '# intentionally absent from pytest collection' | + Set-Content -LiteralPath (Join-Path $Root "samples\uncollected\sample_uncollected.py") -Encoding UTF8 + '# duplicate filename fixture' | + Set-Content -LiteralPath (Join-Path $Root "samples\first\util.py") -Encoding UTF8 + '# duplicate filename fixture' | + Set-Content -LiteralPath (Join-Path $Root "samples\second\util.py") -Encoding UTF8 + + @' +def pytest_addoption(parser): + parser.addoption("--azure_test_run_live") + parser.addoption("--azure_skip_live_recording") + parser.addoption("--direct_required") + parser.addoption("--llm_validation_project_endpoint") +'@ | Set-Content -LiteralPath (Join-Path $Root "conftest.py") -Encoding UTF8 + + @' +[CmdletBinding()] +param([string]$PackageRoot) + +if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot "recording-conflict-check-called.txt"))) { + throw "Recording conflict check did not run before cleanup." +} +New-Item -ItemType File -Path (Join-Path $PackageRoot "recording-cleanup-called.txt") -Force | Out-Null +Remove-Item -Recurse -Force (Join-Path $PackageRoot ".pytest_cache") -ErrorAction SilentlyContinue +'@ | Set-Content -LiteralPath (Join-Path $Root "synthetic-recording-cleanup.ps1") -Encoding UTF8 + + @' +[CmdletBinding()] +param([string]$PackageRoot, [string]$BaseBranch) + +New-Item -ItemType File -Path (Join-Path $PackageRoot "recording-conflict-check-called.txt") -Force | Out-Null +[pscustomobject]@{ AssetRelationship = "synthetic"; SafeToRecord = $true } +'@ | Set-Content -LiteralPath (Join-Path $Root "synthetic-recording-conflict-check.ps1") -Encoding UTF8 + + @' +def loader(**kwargs): + return kwargs + + +servicePreparer = loader( + direct_required="sanitized-direct", + llm_validation_project_endpoint="sanitized-validation-endpoint", +) +'@ | Set-Content -LiteralPath (Join-Path $Root "tests\test_base.py") -Encoding UTF8 + + @' +DIRECT_REQUIRED=real-direct-value +HELPER_REQUIRED=real-helper-value +LLM_VALIDATION_PROJECT_ENDPOINT=https://validation.example.test/api/projects/example +HARNESS_VALUE=real-harness-value +FAILURE_PROCESS_VALUE=real-failure-process-value +'@ | Set-Content -LiteralPath (Join-Path $Root ".env") -Encoding UTF8 + + @' +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[2] +UNIQUE = str(ROOT / "samples" / "unique" / "sample_unique.py") +MULTIPLE = str(ROOT / "samples" / "multiple" / "sample_multiple.py") +RECORD_FAILURE = str(ROOT / "samples" / "failures" / "sample_record_failure.py") +PLAYBACK_FAILURE = str(ROOT / "samples" / "failures" / "sample_playback_failure.py") +HOSTED = str(ROOT / "samples" / "hosted_agents" / "sample_hosted.py") + + +@pytest.mark.parametrize("sample_path", [pytest.param(UNIQUE, id="unique-case")]) +def test_unique(sample_path, pytestconfig): + live = pytestconfig.getoption("azure_test_run_live") == "true" + skip_recording = pytestconfig.getoption("azure_skip_live_recording") == "true" + if live and not skip_recording: + assert (ROOT / "recording-conflict-check-called.txt").is_file() + assert (ROOT / "recording-cleanup-called.txt").is_file() + assert not (ROOT / ".pytest_cache").exists() + if False: + executor.validate_print_calls_by_llm() + + +if False: + AdditionalSampleTestDetail( + test_id="unique-case", + sample_filename="sample_unique.py", + env_vars={"HARNESS_VALUE": "sanitized-harness-value"}, + ) + + +@pytest.mark.parametrize( + "sample_path", + [ + pytest.param(MULTIPLE, id="variant-one"), + pytest.param(MULTIPLE, id="variant-two"), + ], +) +def test_multiple(sample_path): + pass + + +@pytest.mark.parametrize("sample_path", [pytest.param(RECORD_FAILURE, id="record-failure")]) +def test_record_failure(sample_path, pytestconfig): + live = pytestconfig.getoption("azure_test_run_live") == "true" + skip_recording = pytestconfig.getoption("azure_skip_live_recording") == "true" + if live and not skip_recording: + pytest.fail("synthetic recording failure") + (ROOT / "playback-after-record-failure.txt").write_text("played", encoding="utf-8") + + +@pytest.mark.parametrize("sample_path", [pytest.param(PLAYBACK_FAILURE, id="playback-failure")]) +def test_playback_failure(sample_path, pytestconfig): + live = pytestconfig.getoption("azure_test_run_live") == "true" + skip_recording = pytestconfig.getoption("azure_skip_live_recording") == "true" + if not live and skip_recording: + pytest.fail("synthetic playback failure") + + +@pytest.mark.parametrize("sample_path", [pytest.param(HOSTED, id="hosted-case")]) +def test_hosted(sample_path, pytestconfig): + live = pytestconfig.getoption("azure_test_run_live") == "true" + if not live: + assert (ROOT / "tests" / "samples" / "repair-called.txt").is_file() +'@ | Set-Content -LiteralPath (Join-Path $Root "tests\samples\test_samples.py") -Encoding UTF8 + + @' +from pathlib import Path +import sys + +Path(__file__).with_name("repair-called.txt").write_text(sys.argv[1], encoding="utf-8") +print("synthetic repair completed") +'@ | Set-Content -LiteralPath (Join-Path $Root "tests\samples\repair_hosted_agent_sample_recording.py") -Encoding UTF8 + + @' +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ASYNC_SAMPLE = str(ROOT / "samples" / "async" / "sample_async.py") + + +@pytest.mark.parametrize("sample_path", [pytest.param(ASYNC_SAMPLE, id="async-case")]) +async def test_async(sample_path): + pass +'@ | Set-Content -LiteralPath (Join-Path $Root "tests\samples\test_samples_async.py") -Encoding UTF8 + + @' +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[2] +EVALUATION_SAMPLE = str(ROOT / "samples" / "evaluations" / "sample_evaluation.py") + + +@pytest.mark.parametrize("sample_path", [pytest.param(EVALUATION_SAMPLE, id="evaluation-case")]) +def test_evaluation(sample_path): + pass +'@ | Set-Content -LiteralPath (Join-Path $Root "tests\samples\test_samples_evaluations.py") -Encoding UTF8 +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestRecordingGitConflicts.Tests.ps1 b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestRecordingGitConflicts.Tests.ps1 new file mode 100644 index 000000000000..d0401817c6a7 --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-record-sample/tests/TestRecordingGitConflicts.Tests.ps1 @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. (Join-Path $PSScriptRoot "TestHelpers.ps1") +$paths = Get-RecordSampleTestPaths $PSScriptRoot + +function Invoke-TestGit { + param([string]$Root, [Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + $output = @(& git -C $Root @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed: $($output -join [Environment]::NewLine)" + } + ($output -join [Environment]::NewLine).Trim() +} + +function Initialize-TestRepository { + param([string]$Root) + New-Item -ItemType Directory -Path $Root -Force | Out-Null + Invoke-TestGit $Root init --initial-branch=main | Out-Null + Invoke-TestGit $Root config user.email test@example.com | Out-Null + Invoke-TestGit $Root config user.name "Recording Guard Tests" | Out-Null +} + +function Write-TestAssetsFile { + param([string]$PackageRoot, [string]$Tag) + [ordered]@{ + AssetsRepo = "Test/assets" + AssetsRepoPrefixPath = "python" + TagPrefix = "python/ai/azure-ai-projects" + Tag = $Tag + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PackageRoot "assets.json") -Encoding UTF8 +} + +function New-GuardRepositories { + param([string]$Root) + + $sourceRoot = Join-Path $Root "source" + $packageRoot = Join-Path $sourceRoot "sdk\ai\azure-ai-projects" + $assetsRoot = Join-Path $Root "assets" + Initialize-TestRepository $sourceRoot + New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null + Write-TestAssetsFile $packageRoot "asset-base" + Invoke-TestGit $sourceRoot add . | Out-Null + Invoke-TestGit $sourceRoot commit -m "base assets" | Out-Null + + Initialize-TestRepository $assetsRoot + "base" | Set-Content -LiteralPath (Join-Path $assetsRoot "recording.txt") -Encoding UTF8 + Invoke-TestGit $assetsRoot add . | Out-Null + Invoke-TestGit $assetsRoot commit -m "base recording" | Out-Null + Invoke-TestGit $assetsRoot tag asset-base | Out-Null + + [pscustomobject]@{ + SourceRoot = $sourceRoot + PackageRoot = $packageRoot + AssetsRoot = $assetsRoot + } +} + +Describe "TestRecordingGitConflicts.ps1" { + It "allows recording on a current base branch with the same asset tag" { + $repos = New-GuardRepositories (Join-Path $TestDrive "equal-base") + $actual = & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot -BaseBranch main ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + + $actual.SafeToRecord | Should Be $true + $actual.AssetRelationship | Should Be "same" + } + + It "allows a topic branch rebased onto the base with the same asset tag" { + $repos = New-GuardRepositories (Join-Path $TestDrive "topic-equal") + Invoke-TestGit $repos.SourceRoot checkout -b topic | Out-Null + "topic" | Set-Content -LiteralPath (Join-Path $repos.SourceRoot "topic.txt") -Encoding UTF8 + Invoke-TestGit $repos.SourceRoot add . | Out-Null + Invoke-TestGit $repos.SourceRoot commit -m "topic work" | Out-Null + + $actual = & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot -BaseBranch main ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + + $actual.SafeToRecord | Should Be $true + $actual.AssetRelationship | Should Be "same" + } + + It "allows a descendant local asset tag on the base branch" { + $repos = New-GuardRepositories (Join-Path $TestDrive "asset-descendant") + "new" | Set-Content -LiteralPath (Join-Path $repos.AssetsRoot "recording.txt") -Encoding UTF8 + Invoke-TestGit $repos.AssetsRoot add . | Out-Null + Invoke-TestGit $repos.AssetsRoot commit -m "new recording" | Out-Null + Invoke-TestGit $repos.AssetsRoot tag asset-new | Out-Null + Write-TestAssetsFile $repos.PackageRoot "asset-new" + + $actual = & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot -BaseBranch main ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + + $actual.SafeToRecord | Should Be $true + $actual.AssetRelationship | Should Be "descendant" + $actual.BaseAssetCommitDate | Should Not BeNullOrEmpty + $actual.LocalAssetCommitDate | Should Not BeNullOrEmpty + } + + It "blocks a divergent local asset tag" { + $repos = New-GuardRepositories (Join-Path $TestDrive "asset-divergent") + Invoke-TestGit $repos.AssetsRoot checkout --orphan divergent | Out-Null + Invoke-TestGit $repos.AssetsRoot rm -rf . | Out-Null + "divergent" | Set-Content -LiteralPath (Join-Path $repos.AssetsRoot "recording.txt") -Encoding UTF8 + Invoke-TestGit $repos.AssetsRoot add . | Out-Null + Invoke-TestGit $repos.AssetsRoot commit -m "divergent recording" | Out-Null + Invoke-TestGit $repos.AssetsRoot tag asset-divergent | Out-Null + Write-TestAssetsFile $repos.PackageRoot "asset-divergent" + $caught = $null + try { + & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot -BaseBranch main ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + } catch { $caught = $_ } + + $caught.Exception.Message | Should Match "not descended from base tag" + } + + It "blocks a topic branch that does not contain the selected base" { + $repos = New-GuardRepositories (Join-Path $TestDrive "stale-topic") + Invoke-TestGit $repos.SourceRoot checkout -b topic | Out-Null + "topic" | Set-Content -LiteralPath (Join-Path $repos.SourceRoot "topic.txt") -Encoding UTF8 + Invoke-TestGit $repos.SourceRoot add . | Out-Null + Invoke-TestGit $repos.SourceRoot commit -m "topic work" | Out-Null + Invoke-TestGit $repos.SourceRoot checkout main | Out-Null + "new base" | Set-Content -LiteralPath (Join-Path $repos.SourceRoot "base.txt") -Encoding UTF8 + Invoke-TestGit $repos.SourceRoot add . | Out-Null + Invoke-TestGit $repos.SourceRoot commit -m "advance base" | Out-Null + Invoke-TestGit $repos.SourceRoot checkout topic | Out-Null + $caught = $null + try { + & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot -BaseBranch main ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + } catch { $caught = $_ } + + $caught.Exception.Message | Should Match "does not contain the latest 'main'" + } + + It "requires explicit base selection on a topic branch" { + $repos = New-GuardRepositories (Join-Path $TestDrive "missing-base") + Invoke-TestGit $repos.SourceRoot checkout -b topic | Out-Null + $caught = $null + try { + & $paths.RecordingConflictCheck -PackageRoot $repos.PackageRoot ` + -AssetsRepositoryPath $repos.AssetsRoot -SkipFetch + } catch { $caught = $_ } + + $caught.Exception.Message | Should Match "BaseBranch is required" + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-run-issue-regeneration/SKILL.md b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-run-issue-regeneration/SKILL.md new file mode 100644 index 000000000000..8cb11294c4fc --- /dev/null +++ b/sdk/ai/azure-ai-projects/.github/skills/azure-ai-projects-run-issue-regeneration/SKILL.md @@ -0,0 +1,203 @@ +--- +name: azure-ai-projects-run-issue-regeneration +license: MIT +metadata: + version: "1.0.0" + distribution: local +description: "Runs the issue-assigned azure-ai-projects TypeSpec regeneration workflow from required issue-description inputs through a reviewable draft pull request, without the interactive prompts the per-step skills normally ask a human developer. WHEN: \"regen from against \", \"regenerate azure-ai-projects\" issue assignment, issue titled \"[azure-ai-projects] regen from ... against ...\". DO NOT USE FOR: other Azure SDK packages, interactive/local regeneration where a human can answer the emit skill's questions directly (use azure-ai-projects-emit-from-typespec instead). INVOKES: azure-ai-projects-emit-from-typespec, azure-ai-projects-author-samples, azure-ai-projects-author-tests, git, gh CLI." +compatibility: + requires: "local azure-sdk-for-python clone, git, gh CLI, an already-assigned working branch and draft pull request (as created by GitHub issue assignment to Copilot)" +--- + +# Run an issue-assigned TypeSpec regeneration for azure-ai-projects + +Run the `azure-ai-projects` TypeSpec regeneration from its pinned upstream commit through the +already-open draft pull request. Work only in `sdk/ai/azure-ai-projects/` except for repository +setup commands. + +This skill is a thin orchestrator: it does not duplicate the step-by-step instructions already +defined in `azure-ai-projects-emit-from-typespec`, `azure-ai-projects-author-samples`, +and `azure-ai-projects-author-tests`. It validates the issue inputs, then runs those three +skills in order with the specific overrides below so they behave correctly for an unattended, +issue-assigned session instead of an interactive one. + +Changelog updates are outside this workflow. Do not invoke +`azure-ai-projects-update-changelog` or update `CHANGELOG.md`; the SDK maintainer runs that +skill separately at their discretion. + +## Validate the assignment + +Before installing dependencies or editing files: + +1. Read exactly one unambiguous value labeled `TypeSpec commit` and exactly one unambiguous + value labeled `Base branch` from the issue description. If either value is missing, + duplicated, or ambiguous, stop without making changes and report the required labels. +2. Require the TypeSpec commit to match `^[0-9a-f]{40}$` exactly. +3. Require the entire base branch to case-sensitively match the conservative ASCII pattern + `^[A-Za-z0-9][A-Za-z0-9._/-]*$` and pass `git check-ref-format --branch`. Treat it only as a + quoted command argument. +4. Require the current branch to be a working branch other than the base branch. Fetch + `origin/` successfully before proceeding. Do not require `HEAD` to match the + base branch tip; issue-assigned sessions run on a separate working branch that may already + contain commits. Instead, require the working branch to be either (a) ahead of + `origin/` or (b) able to merge or rebase cleanly onto `origin/`. If + neither condition holds, stop without making changes and report the divergence details, + including the merge base and ahead/behind state relative to `origin/`. + +Do not derive either input from the issue title. Do not infer, shorten, or silently correct +either input. + +Confirm a pull request already targets the validated base branch from the current working branch +(`gh pr view --json number,state,baseRefName,isDraft`). Require `state` to equal `OPEN`, +`isDraft` to equal `true`, and `baseRefName` to equal the validated base branch. If the pull +request does not exist, or any of these three checks fails, stop and report the mismatch instead +of creating a new branch or pull request. + +## Managed-session overrides for the called skills + +The three skills below were written for an interactive human developer. Apply these overrides +so they run correctly for this unattended, issue-assigned session. Do not edit the skill files +themselves to apply these overrides. + +- **`azure-ai-projects-emit-from-typespec` Step 1a (tsp-client presence check):** the bare + `tsp-client --version` command is not on `PATH`. `copilot-setup-steps.yml` installs it only + under `eng/common/tsp-client`'s own `node_modules/.bin` for this branch. From the repository + root, check `npm exec --prefix eng/common/tsp-client --no -- tsp-client --version` instead. +- **`azure-ai-projects-emit-from-typespec` Step 1g (install dev dependencies):** do not rerun + `python -m pip install -r dev_requirements.txt`. `copilot-setup-steps.yml` already installed it + for this branch during the setup phase, while it still had full network access. Rerunning it + live only wastes time on repeated failed connections to `pypi.org`, which is not reachable from + this firewalled session (only the Azure SDK package feed on `pkgs.dev.azure.com` is). `azpysdk` + checks reinstall `dev_requirements.txt` themselves against that feed, so every requirement in + it must resolve by package name. Never add a direct wheel URL to it: pip re-downloads those on + every install, even when the package is already installed, and `azpysdk` then fails. +- **`azure-ai-projects-emit-from-typespec` Step 2a (topic branch):** do not create a new topic + branch. Proceed as if the user selected option 3, "Emit to current branch". +- **`azure-ai-projects-emit-from-typespec` Step 2b (TypeSpec source):** proceed as if the user + selected option 3, "TypeSpec commit hash", and supplied the validated 40-character commit from + this issue. +- **`azure-ai-projects-emit-from-typespec` Step 3 (record `BASE_BRANCH`):** use the validated + base branch from this issue, not the result of `git branch --show-current` (the current branch + is the working branch, which is not the pull request's base). +- **`azure-ai-projects-emit-from-typespec` Step 5 (emit SDK from TypeSpec):** the bare + `tsp-client update --debug` command is not on `PATH` either. From `sdk/ai/azure-ai-projects`, + run `npm exec --prefix ../../../eng/common/tsp-client --no -- tsp-client update --debug`. +- **`azure-ai-projects-emit-from-typespec` Steps 7, 9, and 14 (commit message quoting):** each + step's `git commit -m "..."` snippet closes its quote after the title instead of after the + trailer, leaving a stray unmatched quote on the `Co-authored-by` line; executed as shown, this + breaks the shell. Use two `-m` flags instead, which git joins with a blank line between them: + + ```bash + git add -A -- ':!.env*' + git commit -m "Part 1: Emit SDK from TypeSpec" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + git push -u origin HEAD + ``` + + Substitute the matching title (`Part 2: Apply post-emitter-fixes.cmd` for Step 9, `Part 3: + Additional edits` for Step 14) and use `git push -u origin HEAD` for all three steps. +- **`azure-ai-projects-emit-from-typespec` Step 13 (cleanup command):** `rmdir /s /q build` is a + Windows `cmd.exe` command and does not run in this Linux session. Use `rm -rf build` instead. +- **`azure-ai-projects-emit-from-typespec` Step 15 (create a Pull Request):** do not create a + new pull request. The issue assignment already owns the working branch and draft pull request + confirmed above. Push the commits from Steps 7, 9, and 14 to the current branch and leave + finalizing the pull request title and description to the last step of this skill, below. +- All other steps of `azure-ai-projects-emit-from-typespec` (1, 4, and 6 through 14 excluding + the overridden commands above) run exactly as written, including its own STOP conditions. + Still perform Step 4's `git fetch`, but skip its `git switch -c ...` command + since there is no new topic branch to create. +- **`azure-ai-projects-author-samples` Step 5 and `azure-ai-projects-author-tests` Step 3 + (validation commands):** `azpysdk pylint .` runs as written, but two other commands behave + differently here than in CI, so run these variants instead: + - `python -m black --check --config ../../../eng/black-pyproject.toml `. Without the + repository's black configuration (120 columns), black falls back to its 88-column default and + reports most existing files as needing reformatting. Never reformat code with black's defaults. + - `PIP_NO_INPUT=1 azpysdk mypy --isolate .`, instead of `azpysdk mypy .`. CI runs mypy isolated. + In this session's shared `.venv`, mypy sees the installed `openai` package's real types and + reports hundreds of errors in existing samples that CI never sees. `--isolate` builds a + separate environment (under `.venv/`) from the Azure SDK package feed, which is reachable from + this session, and leaves the shared `.venv` untouched. If that environment cannot be created + (for example `401 Unauthorized` for a package file the feed has not cached), the check cannot + run: stop and report it instead of retrying or falling back to the non-isolated form. This + applies only when the check cannot run. Errors it reports once it does run are source problems + to fix (see "Run the skills in order"). +- **`azure-ai-projects-author-samples` Step 6 and `azure-ai-projects-author-tests` Step 4 + (hand off):** keep their reporting requirements, but skip their follow-on skill invocations. + This orchestrator runs samples and tests in the order below and never invokes + `azure-ai-projects-update-changelog`. + +`azure-ai-projects-author-samples` and `azure-ai-projects-author-tests` do not create branches +or pull requests, so they need no branch/PR overrides. Neither skill commits its own changes; +stage, commit, and push any remaining changes yourself after both finish (see below). + +## Run the skills in order + +Read each `SKILL.md` in full immediately before executing it, and execute them in this exact +order: + +1. `.github/skills/azure-ai-projects-emit-from-typespec/SKILL.md`, with the overrides above. +2. `.github/skills/azure-ai-projects-author-samples/SKILL.md`. +3. `.github/skills/azure-ai-projects-author-tests/SKILL.md`. + +Do not proceed to the next skill until the current skill's success criteria pass. Apply every +STOP condition from the three skills: on failure, preserve the working tree for diagnosis, do not +publish partial manual branches or pull requests, and report the failing command and its output. + +A STOP condition is a failure of the workflow itself, not a finding in the code you are updating: +a tool or environment that cannot run (for example, the isolated mypy environment cannot be +created), a failing emit or post-emitter script, or a needed change outside the files these skills +may edit (for example, generated code under `azure/`). A finding that Black, mypy, or test +collection reports in a sample or test is a "source problem" to fix, as +`azure-ai-projects-author-samples` Step 5 already says; the tests skill works the same way. For +example, when the emitted API makes a model field optional and mypy reports `str | None` passed +where `str` is required, guard the value in the sample or assert it in the test. Fix the code, +rerun the failed command, and continue. Never silence a finding with `# type: ignore`, a lint +suppression, or relaxed settings. If the same command still fails after two fix attempts, stop and +report the command, its output, and the fixes you tried. + +Samples and tests are conditional. A step may be a documented no-op when the API diff contains +no qualifying surface; state that explicitly in the pull request rather than creating +placeholder files. + +## Commit the remaining changes + +After the sample and test skills finish, stage, commit, and push any remaining changes, +including refreshed API artifacts: + +```bash +git add -A -- ':!.env*' +git commit -m "Part 4: Update samples and tests" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +git push -u origin HEAD +``` + +If there are no remaining changes, skip this commit and report the no-op in the pull request. + +## Finish the managed pull request + +Keep the pull request in draft. The description will contain Markdown code spans for API names +(e.g. `` `AgentDetails` ``); a double-quoted `gh pr edit --body "..."` argument would let Bash +expand those backticks as command substitutions and silently drop their contents. Write the +description to a file with a quoted heredoc (which disables all shell expansion inside it), then +pass that file to `gh pr edit` instead: + +```bash +cat > /tmp/pr-body.md <<'EOF' + +EOF +gh pr edit \ + --title "[azure-ai-projects] Regenerate from azure-rest-api-specs@<7-character-commit>" \ + --body-file /tmp/pr-body.md +``` + +The description must: + +- Link the full upstream commit. +- Summarize public API changes from the API diff against the validated base branch. +- Explain any sample or test no-ops. +- State that changelog updates are deferred to the SDK maintainer's discretion. +- Report each validation command from the three skills honestly, including any that were skipped + and why. + +Before finishing, verify that no diff3 conflict markers remain under `azure/`, that the package +installs cleanly from source (`azure-ai-projects-emit-from-typespec` Step 12 already did this), +and that all remaining changes were committed and pushed. Never report the regeneration as +complete while any required skill is pending or failed. diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index dae1c498f09b..7f18fa75ba0c 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -1,12 +1,54 @@ # Release History -## 2.9.0 (Unreleased) +## 2.10.0 (2026-10-30) + +### Features Added + +* Added optional `VoiceAgentDefinition.soundscape` for built-in background ambience and event cues in telephony calls, with `VoiceAgentSoundscapeConfig`, `VoiceAgentSoundscapeSourceConfig`, `VoiceAgentSoundscapeTrackConfig`, `VoiceAgentSoundscapeWorkingTrackConfig`, and `VoiceAgentSoundscapeSound`. +* Added optional `VoiceAgentInputTranscription.profanity_filter` and `VoiceAgentProfanityFilterMode` for configuring Azure Speech input-transcript filtering before a session starts. +* Added `VoiceAgentInputTranscriptionModel.AZURE_SPEECH_STREAMING` for Azure Speech streaming transcription in cascaded text-model conversations. This model requires a nonempty `VoiceAgentInputTranscription.language` and is not supported with realtime speech-to-speech models. +* Added `VoiceAgentNoiseReductionType.AZURE_DEEP_NOISE_SUPPRESSION_HEADSET` and `VoiceAgentNoiseReductionType.AZURE_PERSONAL_NOISE_SUPPRESSION` for local voice activity detection without changing recognition audio. +* Added `AgentEndpointProtocol.VOICE_CONVERSATION_ENGINE` for hosted text agents used as Voice Agent conversation engines. Hosted agents must declare this protocol with version `1.0.0`; `invocations_ws` does not substitute for it. +* Added optional `HostedAgentDefinition.enable_memory_snapshot` to start hosted-agent sessions from a snapshot of the initialized process instead of starting the process from its image. +* Added known `ImageGenTool.model` values `gpt-image-2.5-sunburst`, `gpt-image-2.5-sunburst-2026-09-08`, `gpt-image-2.5-flare`, and `gpt-image-2.5-flare-2026-09-08`, with `xhigh` and `max` quality options for these models. + +### Bugs Fixed + +* Fixed tracing to record `temperature=0` and `top_p=0` instead of omitting these valid values. +* Fixed `SimulationSeedDataGenerationJobConfiguration` to expose the service-required `max_samples` property and constructor argument. Updated the simulation-seed evaluation sample to pass this argument directly instead of using the mapping workaround. + +## 2.9.0 (2026-10-16) + +### Features Added + +* Added experimental Voice Agents REST and realtime tracing through `AIProjectInstrumentor`, including correlated session, connection and response spans, tool/lifecycle events, aggregate audio sizes and existing GenAI response metrics. Existing tracing and content-recording settings apply; message content is not captured by default. +* Added an optional `transport` keyword on synchronous and asynchronous realtime `connect()` using `VoiceAgentTransport.WEBSOCKET` or `VoiceAgentTransport.WEBRTC`. The SDK constructs the transport wire query internally. +* Added preview Microsoft Copilot managed harness and toolset support for prompt agents. + +### Breaking Changes + +All breaking changes affect preview APIs. + +Breaking changes in preview classes: + +* Renamed `CreateTeamsPhoneExtensionTelephonyBindingRequest`, `TeamsPhoneExtensionTelephonyBinding`, and `TeamsPhoneExtensionTelephonyBindingListItem` to `CreateTeamsPhoneExtensibilityTelephonyBindingRequest`, `TeamsPhoneExtensibilityTelephonyBinding`, and `TeamsPhoneExtensibilityTelephonyBindingListItem`, respectively. +* Renamed `TelephonyProvider.TEAMS_PHONE_EXTENSION` and `TelephonyCallLifecycleEventSource.TEAMS_PHONE_EXTENSION` to `TEAMS_PHONE_EXTENSIBILITY`; their wire value changed from `teams_phone_extension` to `teams_phone_extensibility`. +* Removed `VoiceAgentRealtimeResponseBase` and `VoiceResponseBase`; their properties now live directly on `VoiceAgentRealtimeResponse` and `VoiceResponse`. Several persisted `VoiceResponse` fields are now optional, and `metadata` now uses the `Metadata` model. + +### Bugs Fixed + +* Fixed synchronous and asynchronous Azure AI Projects long-running operations to treat both `cancelled` and `canceled` as terminal cancellation statuses without changing Azure Core polling behavior for other SDKs. ### Sample updates +* Added `sample_log_from_voice_agent.py` in `samples/logs`, demonstrating Voice Agent realtime diagnostics through the `azure.ai.projects.realtime` logger without OpenTelemetry setup. +* Fixed the realtime audio conversation sample to enable input transcription, count only successfully sent audio payloads, and finish pending capture sends before printing transfer totals. +* Fixed the conversation deletion snippet to handle the API's no-content response. * Updated Voice Agent samples to use `FOUNDRY_VOICE_AGENT_MODEL` for model configuration, retaining `FOUNDRY_VOICE_MODEL` as a deprecated fallback in `sample_voice_agent_with_tools.py`. * Renamed Voice Agent realtime samples from `sample_voice_agent_live_*` to `sample_voice_agent_realtime_*`, updating usage instructions and cross-references. * Updated `sample_multiturn_trace_evaluation_agent_filter.py` to use the supported agent-name/version filter, remove unsupported command-line filtering options, and configure trace lookback and maximum trace count through `TRACE_LOOKBACK_HOURS` and `TRACE_MAX_TRACES`. +* Updated `sample_agent_browser_automation.py` to handle the GA `remote_function_call` browser action items, print complete agent messages instead of per-token deltas, and demonstrate reading the title and description of example.com. +* Updated Agent Optimization job creation, polling, and cancellation samples to use inline target-completion test cases instead of requiring a registered dataset; `JOB_ID` is now optional in `sample_optimization_job_list_get_delete.py`. ## 2.8.0 (2026-10-02) diff --git a/sdk/ai/azure-ai-projects/PostEmitter.ps1 b/sdk/ai/azure-ai-projects/PostEmitter.ps1 index c5a7d4fbdead..ee2644b22ca3 100644 --- a/sdk/ai/azure-ai-projects/PostEmitter.ps1 +++ b/sdk/ai/azure-ai-projects/PostEmitter.ps1 @@ -192,52 +192,6 @@ foreach ($f in $files) { Set-Content $f $out } -# Remove duplicate top-level variable declarations from generated _unions.py. -# Keep the first declaration for each name and remove any later declarations, including multiline aliases. -$unionsFile = Resolve-Path 'azure\ai\projects\_unions.py' -$deduplicateUnionsScript = @' -import ast -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -source = path.read_text(encoding="utf-8-sig") -tree = ast.parse(source, filename=str(path)) -seen = set() -duplicate_ranges = [] - -for statement in tree.body: - names = [] - if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name): - names = [statement.targets[0].id] - elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): - names = [statement.target.id] - - duplicate_names = [name for name in names if name in seen] - if duplicate_names: - duplicate_ranges.append((statement.lineno, statement.end_lineno)) - else: - seen.update(names) - -if duplicate_ranges: - lines = source.splitlines(keepends=True) - for start, end in reversed(duplicate_ranges): - del lines[start - 1 : end] - path.write_text("".join(lines), encoding="utf-8") -'@ - -$deduplicateUnionsScriptFile = Join-Path ([System.IO.Path]::GetTempPath()) ("azure-ai-projects-deduplicate-unions-{0}.py" -f [guid]::NewGuid().ToString('N')) -Set-Content $deduplicateUnionsScriptFile $deduplicateUnionsScript -Encoding utf8 -try { - & (Get-Command python -ErrorAction Stop).Source $deduplicateUnionsScriptFile $unionsFile - if ($LASTEXITCODE -ne 0) { - throw "Duplicate union cleanup failed with exit code $LASTEXITCODE." - } -} -finally { - Remove-Item $deduplicateUnionsScriptFile -ErrorAction SilentlyContinue -} - # Regenerate API review artifacts and the public method inventory. $pythonExecutable = (Get-Command python -ErrorAction Stop).Source & $pythonExecutable -m pip install --no-deps --editable . diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 889e074dd519..c6d5e28f1331 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -66,6 +66,7 @@ To report an issue with the client library, or request additional features, plea * Python 3.10 or later. * An [Azure subscription][azure_sub]. * A [project in Microsoft Foundry](https://learn.microsoft.com/azure/foundry/how-to/create-projects). +* An [Application Insights resource connected to your Foundry project](https://learn.microsoft.com/azure/foundry/observability/how-to/trace-agent-setup) for agent tracing. * A Foundry project endpoint URL of the form `https://your-ai-services-account-name.services.ai.azure.com/api/projects/your-project-name`. It can be found in your Microsoft Foundry Project home page. Below we will assume the environment variable `FOUNDRY_PROJECT_ENDPOINT` was defined to hold this value. * Client authentication is done using Entra ID. To authenticate, your application needs an object that implements the [TokenCredential](https://learn.microsoft.com/python/api/azure-core/azure.core.credentials.tokencredential) interface. Code samples here use [DefaultAzureCredential](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential). To get that working, you will need: * An appropriate role assignment. See [Role-based access control in Microsoft Foundry portal](https://learn.microsoft.com/azure/foundry/concepts/rbac-foundry). Role assignment can be done via the "Access Control (IAM)" tab of your Azure AI Project resource in the Azure portal. @@ -162,6 +163,7 @@ See the **responses** folder in the [samples][samples] for additional samples in See Foundry documentation: * **[Microsoft Foundry Agents overview](https://learn.microsoft.com/azure/foundry/agents/overview)** — concepts, setup, and quick-starts. +* **[Quickstart: Set up Microsoft Foundry resources](https://learn.microsoft.com/azure/foundry/tutorials/quickstart-create-foundry-resources)** — create a project, deploy a model, and connect Application Insights for agent tracing. * **[Runtime components](https://learn.microsoft.com/azure/foundry/agents/concepts/runtime-components?tabs=python)** — deep-dive into agent architecture. * **[Tool catalog](https://learn.microsoft.com/azure/foundry/agents/concepts/tool-catalog)** — all available tools and agent capabilities. * **[SDK samples folder][samples]** — fully runnable Python code for synchronous and asynchronous clients covering all operations below. @@ -195,10 +197,40 @@ The table below lists the operation groups supported by the client library, with | Toolboxes | [Curate intent-based toolbox in Foundry](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox?pivots=python) | `samples/hosted_agents/`, `samples/toolboxes/` | | Voice agents (preview) | | `samples/agents/voice/` | +### Voice Agent realtime transport (preview) + +Select the connection transport with the optional `transport` keyword. The SDK constructs the wire +query internally; callers do not need to put the transport in `extra_query`. Omitting the option +preserves the service's default WebSocket behavior. + +```python +from azure.ai.projects.models import VoiceAgentTransport + +with project_client.beta.voice_agents.realtime.connect( + agent_name="my-voice-agent", + transport=VoiceAgentTransport.WEBSOCKET, +) as connection: + print("Voice Agent connection is ready.") +``` + +The asynchronous client uses the same keyword: + +```python +async with async_project_client.beta.voice_agents.realtime.connect( + agent_name="my-voice-agent", + transport=VoiceAgentTransport.WEBSOCKET, +) as connection: + print("Voice Agent connection is ready.") +``` + +`VoiceAgentTransport.WEBRTC` selects WebRTC signaling over the WebSocket. The application must manage +the peer connection, media, and SDP offer/answer exchange using `rtc.call.sdp.create` and +`rtc.call.sdp.created` events. Selecting this value does not automatically create a WebRTC media client. ## Client-side tracing See [Add client-side tracing to Foundry agents (preview)](https://learn.microsoft.com/azure/foundry/observability/how-to/trace-agent-client-side?tabs=python). +The same global Foundry tracing configuration applies to Voice Agents REST and realtime tracing. **Important:** GenAI tracing instrumentation is an experimental preview feature. Spans, attributes, and events may be modified in future versions. @@ -286,7 +318,7 @@ project_client = AIProjectClient( Note that the log level must be set to `logging.DEBUG` (see above code). Logs will be redacted with any other log level. -See the logging samples in the `samples/logs/` folder for complete end-to-end examples, including console logging, file logging, and OpenAI transport logging. +See the logging samples in the `samples/logs/` folder for complete end-to-end examples, including console logging, file logging, OpenAI transport logging, and Voice Agent realtime logging. Be sure to protect non-redacted logs to avoid compromising security. diff --git a/sdk/ai/azure-ai-projects/api.md b/sdk/ai/azure-ai-projects/api.md index 698811b7a335..84ed78a467fa 100644 --- a/sdk/ai/azure-ai-projects/api.md +++ b/sdk/ai/azure-ai-projects/api.md @@ -654,6 +654,7 @@ namespace azure.ai.projects.aio.operations extra_headers: Optional[Mapping[str, str]] = ..., extra_query: Optional[Mapping[str, str]] = ..., structured_inputs: Optional[Mapping[str, Any]] = ..., + transport: Optional[Union[str, VoiceAgentTransport]] = ..., **kwargs: Any ) -> AsyncBetaRealtimeConnectionManager: ... @@ -698,6 +699,7 @@ namespace azure.ai.projects.aio.operations extra_headers: Optional[Mapping[str, str]] = ..., extra_query: Optional[Mapping[str, str]] = ..., structured_inputs: Optional[Mapping[str, Any]] = ..., + transport: Optional[Union[str, VoiceAgentTransport]] = ..., **kwargs: Any ) -> None: ... @@ -3514,6 +3516,7 @@ namespace azure.ai.projects.models MCP = "mcp" RESPONSES = "responses" VOICE = "voice" + VOICE_CONVERSATION_ENGINE = "voice_conversation_engine" class azure.ai.projects.models.AgentEvaluatorGenerationJobSource(EvaluatorGenerationJobSource, discriminator='agent'): @@ -4482,6 +4485,8 @@ namespace azure.ai.projects.models **kwargs: Any ) -> AgentOptimizationLROPoller: ... + def status(self) -> str: ... + class azure.ai.projects.models.AgentOptimizationModelConfiguration(_Model): model: str @@ -5094,6 +5099,8 @@ namespace azure.ai.projects.models **kwargs: Any ) -> AsyncAgentOptimizationLROPoller: ... + def status(self) -> str: ... + class azure.ai.projects.models.AsyncDatasetGenerationLROPoller(AsyncLROPoller[DataGenerationJobResult]): property details: Mapping[str, Any] # Read-only @@ -6256,11 +6263,11 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... - class azure.ai.projects.models.CreateTeamsPhoneExtensionTelephonyBindingRequest(CreateTelephonyBindingRequest, discriminator='teams_phone_extension'): + class azure.ai.projects.models.CreateTeamsPhoneExtensibilityTelephonyBindingRequest(CreateTelephonyBindingRequest, discriminator='teams_phone_extensibility'): connection_name: str label: str phone_number: Optional[str] - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] resource_account_object_id: str @overload @@ -8334,6 +8341,7 @@ namespace azure.ai.projects.models code_configuration: Optional[CodeConfiguration] container_configuration: Optional[ContainerConfiguration] cpu: str + enable_memory_snapshot: Optional[bool] environment_variables: Optional[dict[str, str]] kind: Literal[AgentKind.HOSTED] memory: str @@ -8349,6 +8357,7 @@ namespace azure.ai.projects.models code_configuration: Optional[CodeConfiguration] = ..., container_configuration: Optional[ContainerConfiguration] = ..., cpu: str, + enable_memory_snapshot: Optional[bool] = ..., environment_variables: Optional[dict[str, str]] = ..., memory: str, protocol_versions: Optional[list[ProtocolVersionRecord]] = ..., @@ -9428,6 +9437,71 @@ namespace azure.ai.projects.models TENANT = "Tenant" + class azure.ai.projects.models.MicrosoftCopilotBuiltInTool(str, Enum, metaclass=CaseInsensitiveEnumMeta): + FILESYSTEM_READ = "filesystem_read" + FILESYSTEM_WRITE = "filesystem_write" + SHELL = "shell" + SUBAGENTS = "subagents" + WEB = "web" + + + class azure.ai.projects.models.MicrosoftCopilotHarness(AgentHarness, discriminator='microsoft_copilot_preview'): + type: Literal["microsoft_copilot_preview"] + + @overload + def __init__(self) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.MicrosoftCopilotToolsetConfig(_Model): + enabled: Optional[bool] + name: Union[str, MicrosoftCopilotBuiltInTool] + + @overload + def __init__( + self, + *, + enabled: Optional[bool] = ..., + name: Union[str, MicrosoftCopilotBuiltInTool] + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.MicrosoftCopilotToolsetDefaultConfig(_Model): + enabled: Optional[bool] + + @overload + def __init__( + self, + *, + enabled: Optional[bool] = ... + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.MicrosoftCopilotToolsetPreview(Tool, discriminator='microsoft_copilot_toolset_preview'): + configs: Optional[list[MicrosoftCopilotToolsetConfig]] + default_config: Optional[MicrosoftCopilotToolsetDefaultConfig] + type: Literal[ToolType.MICROSOFT_COPILOT_TOOLSET_PREVIEW] + + @overload + def __init__( + self, + *, + configs: Optional[list[MicrosoftCopilotToolsetConfig]] = ..., + default_config: Optional[MicrosoftCopilotToolsetDefaultConfig] = ... + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + class azure.ai.projects.models.MicrosoftFabricPreviewTool(Tool, discriminator='fabric_dataagent_preview'): fabric_dataagent_preview: FabricDataAgentToolParameters type: Literal[ToolType.FABRIC_DATAAGENT_PREVIEW] @@ -12946,6 +13020,7 @@ namespace azure.ai.projects.models class azure.ai.projects.models.SimulationSeedDataGenerationJobConfiguration(DataGenerationJobConfiguration, discriminator='simulation_seed'): + max_samples: int model_options: DataGenerationModelOptions train_split: float type: Literal[DataGenerationJobType.SIMULATION_SEED] @@ -12954,6 +13029,7 @@ namespace azure.ai.projects.models def __init__( self, *, + max_samples: int, model_options: Optional[DataGenerationModelOptions] = ..., train_split: Optional[float] = ... ) -> None: ... @@ -13293,13 +13369,13 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... - class azure.ai.projects.models.TeamsPhoneExtensionTelephonyBinding(TelephonyBinding, discriminator='teams_phone_extension'): + class azure.ai.projects.models.TeamsPhoneExtensibilityTelephonyBinding(TelephonyBinding, discriminator='teams_phone_extensibility'): connection_name: str id: str incoming_call_url: str label: str phone_number: Optional[str] - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] resource_account_object_id: str status: Union[str, TelephonyBindingStatus] @@ -13320,14 +13396,14 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... - class azure.ai.projects.models.TeamsPhoneExtensionTelephonyBindingListItem(TelephonyBindingListItem, discriminator='teams_phone_extension'): + class azure.ai.projects.models.TeamsPhoneExtensibilityTelephonyBindingListItem(TelephonyBindingListItem, discriminator='teams_phone_extensibility'): connection_name: str etag: str id: str incoming_call_url: str label: str phone_number: Optional[str] - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] resource_account_object_id: str status: Union[str, TelephonyBindingStatus] @@ -13735,7 +13811,7 @@ namespace azure.ai.projects.models class azure.ai.projects.models.TelephonyCallLifecycleEventSource(str, Enum, metaclass=CaseInsensitiveEnumMeta): GATEWAY = "gateway" - TEAMS_PHONE_EXTENSION = "teams_phone_extension" + TEAMS_PHONE_EXTENSIBILITY = "teams_phone_extensibility" TWILIO = "twilio" VOICE_AGENT = "voice_agent" @@ -13997,7 +14073,7 @@ namespace azure.ai.projects.models class azure.ai.projects.models.TelephonyProvider(str, Enum, metaclass=CaseInsensitiveEnumMeta): - TEAMS_PHONE_EXTENSION = "teams_phone_extension" + TEAMS_PHONE_EXTENSIBILITY = "teams_phone_extensibility" TWILIO = "twilio" @@ -14452,6 +14528,7 @@ namespace azure.ai.projects.models LOCAL_SHELL = "local_shell" MCP = "mcp" MEMORY_SEARCH_PREVIEW = "memory_search_preview" + MICROSOFT_COPILOT_TOOLSET_PREVIEW = "microsoft_copilot_toolset_preview" NAMESPACE = "namespace" OPENAPI = "openapi" PROGRAMMATIC_TOOL_CALLING = "programmatic_tool_calling" @@ -15643,6 +15720,7 @@ namespace azure.ai.projects.models output_modalities: Optional[list[Union[str, VoiceOutputModality]]] parallel_tool_calls: Optional[bool] rai_config: RaiConfig + soundscape: Optional[VoiceAgentSoundscapeConfig] store: Optional[bool] structured_inputs: Optional[dict[str, StructuredInputDefinition]] subagent_config: Optional[VoiceAgentSubagentConfig] @@ -15666,6 +15744,7 @@ namespace azure.ai.projects.models output_modalities: Optional[list[Union[str, VoiceOutputModality]]] = ..., parallel_tool_calls: Optional[bool] = ..., rai_config: Optional[RaiConfig] = ..., + soundscape: Optional[VoiceAgentSoundscapeConfig] = ..., store: Optional[bool] = ..., structured_inputs: Optional[dict[str, StructuredInputDefinition]] = ..., subagent_config: Optional[VoiceAgentSubagentConfig] = ..., @@ -15788,6 +15867,7 @@ namespace azure.ai.projects.models languages: Optional[list[str]] model: Union[str, VoiceAgentInputTranscriptionModel] phrase_list: Optional[list[str]] + profanity_filter: Optional[Union[str, VoiceAgentProfanityFilterMode]] prompt: Optional[str] @overload @@ -15801,6 +15881,7 @@ namespace azure.ai.projects.models languages: Optional[list[str]] = ..., model: Union[str, VoiceAgentInputTranscriptionModel], phrase_list: Optional[list[str]] = ..., + profanity_filter: Optional[Union[str, VoiceAgentProfanityFilterMode]] = ..., prompt: Optional[str] = ... ) -> None: ... @@ -15810,6 +15891,7 @@ namespace azure.ai.projects.models class azure.ai.projects.models.VoiceAgentInputTranscriptionModel(str, Enum, metaclass=CaseInsensitiveEnumMeta): AZURE_SPEECH = "azure-speech" + AZURE_SPEECH_STREAMING = "azure-speech-streaming" GPT4_O_MINI_TRANSCRIBE = "gpt-4o-mini-transcribe" GPT4_O_TRANSCRIBE = "gpt-4o-transcribe" GPT4_O_TRANSCRIBE_DIARIZE = "gpt-4o-transcribe-diarize" @@ -15936,50 +16018,26 @@ namespace azure.ai.projects.models class azure.ai.projects.models.VoiceAgentNoiseReductionType(str, Enum, metaclass=CaseInsensitiveEnumMeta): AZURE_DEEP_NOISE_SUPPRESSION = "azure_deep_noise_suppression" + AZURE_DEEP_NOISE_SUPPRESSION_HEADSET = "azure_deep_noise_suppression_headset" + AZURE_PERSONAL_NOISE_SUPPRESSION = "azure_personal_noise_suppression" FAR_FIELD = "far_field" NEAR_FIELD = "near_field" - class azure.ai.projects.models.VoiceAgentRealtimeResponse(VoiceAgentRealtimeResponseBase): - audio: Optional[VoiceResponseAudio] - conversation_id: str - id: str - max_output_tokens: Union[int, str] - metadata: Metadata - object: str - output: Optional[list[RealtimeConversationItem]] - output_modalities: Union[list[str, str]] - status: Union[str, str, str, str, str] - status_details: RealtimeResponseStatusDetails - usage: RealtimeResponseUsage - - @overload - def __init__( - self, - *, - audio: Optional[VoiceResponseAudio] = ..., - conversation_id: Optional[str] = ..., - id: Optional[str] = ..., - max_output_tokens: Optional[Union[int, Literal[inf]]] = ..., - metadata: Optional[Metadata] = ..., - object: Optional[Literal[response]] = ..., - output: Optional[list[RealtimeConversationItem]] = ..., - output_modalities: Optional[list[Literal[text, audio]]] = ..., - status: Optional[Literal[completed, cancelled, failed, incomplete, in_progress]] = ..., - status_details: Optional[RealtimeResponseStatusDetails] = ..., - usage: Optional[RealtimeResponseUsage] = ... - ) -> None: ... - - @overload - def __init__(self, mapping: Mapping[str, Any]) -> None: ... + class azure.ai.projects.models.VoiceAgentProfanityFilterMode(str, Enum, metaclass=CaseInsensitiveEnumMeta): + MASKED = "masked" + REMOVED = "removed" + UNFILTERED = "unfiltered" - class azure.ai.projects.models.VoiceAgentRealtimeResponseBase(_Model): + class azure.ai.projects.models.VoiceAgentRealtimeResponse(_Model): + audio: Optional[VoiceResponseAudio] conversation_id: Optional[str] id: Optional[str] max_output_tokens: Optional[Union[int, Literal["inf"]]] metadata: Optional[Metadata] object: Optional[Literal["response"]] + output: Optional[list[RealtimeConversationItem]] output_modalities: Optional[list[Literal["text", "audio"]]] status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] status_details: Optional[RealtimeResponseStatusDetails] @@ -15989,11 +16047,13 @@ namespace azure.ai.projects.models def __init__( self, *, + audio: Optional[VoiceResponseAudio] = ..., conversation_id: Optional[str] = ..., id: Optional[str] = ..., max_output_tokens: Optional[Union[int, Literal[inf]]] = ..., metadata: Optional[Metadata] = ..., object: Optional[Literal[response]] = ..., + output: Optional[list[RealtimeConversationItem]] = ..., output_modalities: Optional[list[Literal[text, audio]]] = ..., status: Optional[Literal[completed, cancelled, failed, incomplete, in_progress]] = ..., status_details: Optional[RealtimeResponseStatusDetails] = ..., @@ -16606,6 +16666,87 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... + class azure.ai.projects.models.VoiceAgentSoundscapeConfig(_Model): + background: Optional[VoiceAgentSoundscapeTrackConfig] + closing: Optional[VoiceAgentSoundscapeTrackConfig] + opening: Optional[VoiceAgentSoundscapeTrackConfig] + transfer: Optional[VoiceAgentSoundscapeTrackConfig] + working: Optional[VoiceAgentSoundscapeWorkingTrackConfig] + + @overload + def __init__( + self, + *, + background: Optional[VoiceAgentSoundscapeTrackConfig] = ..., + closing: Optional[VoiceAgentSoundscapeTrackConfig] = ..., + opening: Optional[VoiceAgentSoundscapeTrackConfig] = ..., + transfer: Optional[VoiceAgentSoundscapeTrackConfig] = ..., + working: Optional[VoiceAgentSoundscapeWorkingTrackConfig] = ... + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.VoiceAgentSoundscapeSound(str, Enum, metaclass=CaseInsensitiveEnumMeta): + BUSY_STREET = "busy_street" + CALL_CENTER = "call_center" + DING1 = "ding1" + DING2 = "ding2" + OFFICE = "office" + PEOPLE_TALKING = "people_talking" + STREET_AND_METRO_STATION = "street_and_metro_station" + + + class azure.ai.projects.models.VoiceAgentSoundscapeSourceConfig(_Model): + name: Union[str, VoiceAgentSoundscapeSound] + type: Literal["built_in"] + + @overload + def __init__( + self, + *, + name: Union[str, VoiceAgentSoundscapeSound] + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig(_Model): + gain: Optional[float] + source: VoiceAgentSoundscapeSourceConfig + + @overload + def __init__( + self, + *, + gain: Optional[float] = ..., + source: VoiceAgentSoundscapeSourceConfig + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + + class azure.ai.projects.models.VoiceAgentSoundscapeWorkingTrackConfig(_Model): + delay_ms: Optional[timedelta] + gain: Optional[float] + source: VoiceAgentSoundscapeSourceConfig + + @overload + def __init__( + self, + *, + delay_ms: Optional[timedelta] = ..., + gain: Optional[float] = ..., + source: VoiceAgentSoundscapeSourceConfig + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: ... + + class azure.ai.projects.models.VoiceAgentStaticInterimResponseConfig(VoiceAgentInterimResponseConfig, discriminator='static_interim_response'): latency_threshold_ms: timedelta texts: Optional[list[str]] @@ -16814,6 +16955,11 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... + class azure.ai.projects.models.VoiceAgentTransport(str, Enum, metaclass=CaseInsensitiveEnumMeta): + WEBRTC = "webrtc" + WEBSOCKET = "websocket" + + class azure.ai.projects.models.VoiceAgentTurnDetectionConfig(_Model): auto_truncate: Optional[bool] type: str @@ -17030,21 +17176,21 @@ namespace azure.ai.projects.models ) -> None: ... - class azure.ai.projects.models.VoiceResponse(VoiceResponseBase): + class azure.ai.projects.models.VoiceResponse(_Model): audio: Optional[VoiceResponseAudio] completed_at: Optional[datetime] - conversation_id: str + conversation_id: Optional[str] created_at: Optional[datetime] - id: str - max_output_tokens: Union[int, str] - metadata: Optional[dict[str, str]] - object: str + id: Optional[str] + max_output_tokens: Optional[Union[int, Literal["inf"]]] + metadata: Optional[Metadata] + object: Optional[Literal["response"]] output: Optional[list[RealtimeConversationItem]] - output_modalities: Union[list[str, str]] - status: Union[str, str, str, str, str] - status_details: RealtimeResponseStatusDetails + output_modalities: Optional[list[Literal["text", "audio"]]] + status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] + status_details: Optional[RealtimeResponseStatusDetails] temperature: Optional[float] - usage: RealtimeResponseUsage + usage: Optional[RealtimeResponseUsage] @overload def __init__( @@ -17052,11 +17198,11 @@ namespace azure.ai.projects.models *, audio: Optional[VoiceResponseAudio] = ..., completed_at: Optional[datetime] = ..., - conversation_id: str, + conversation_id: Optional[str] = ..., created_at: Optional[datetime] = ..., - id: str, + id: Optional[str] = ..., max_output_tokens: Optional[Union[int, Literal[inf]]] = ..., - metadata: Optional[dict[str, str]] = ..., + metadata: Optional[Metadata] = ..., object: Optional[Literal[response]] = ..., output: Optional[list[RealtimeConversationItem]] = ..., output_modalities: Optional[list[Literal[text, audio]]] = ..., @@ -17104,30 +17250,6 @@ namespace azure.ai.projects.models def __init__(self, mapping: Mapping[str, Any]) -> None: ... - class azure.ai.projects.models.VoiceResponseBase(_Model): - max_output_tokens: Optional[Union[int, Literal["inf"]]] - object: Optional[Literal["response"]] - output_modalities: Optional[list[Literal["text", "audio"]]] - status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] - status_details: Optional[RealtimeResponseStatusDetails] - usage: Optional[RealtimeResponseUsage] - - @overload - def __init__( - self, - *, - max_output_tokens: Optional[Union[int, Literal[inf]]] = ..., - object: Optional[Literal[response]] = ..., - output_modalities: Optional[list[Literal[text, audio]]] = ..., - status: Optional[Literal[completed, cancelled, failed, incomplete, in_progress]] = ..., - status_details: Optional[RealtimeResponseStatusDetails] = ..., - usage: Optional[RealtimeResponseUsage] = ... - ) -> None: ... - - @overload - def __init__(self, mapping: Mapping[str, Any]) -> None: ... - - class azure.ai.projects.models.VoiceType(str, Enum, metaclass=CaseInsensitiveEnumMeta): AVATAR_VOICE_SYNC = "avatar-voice-sync" AZURE_CUSTOM = "azure-custom" @@ -18867,6 +18989,7 @@ namespace azure.ai.projects.operations extra_headers: Optional[Mapping[str, str]] = ..., extra_query: Optional[Mapping[str, str]] = ..., structured_inputs: Optional[Mapping[str, Any]] = ..., + transport: Optional[Union[str, VoiceAgentTransport]] = ..., **kwargs: Any ) -> BetaRealtimeConnectionManager: ... @@ -18911,6 +19034,7 @@ namespace azure.ai.projects.operations extra_headers: Optional[Mapping[str, str]] = ..., extra_query: Optional[Mapping[str, str]] = ..., structured_inputs: Optional[Mapping[str, Any]] = ..., + transport: Optional[Union[str, VoiceAgentTransport]] = ..., **kwargs: Any ) -> None: ... diff --git a/sdk/ai/azure-ai-projects/api.metadata.yml b/sdk/ai/azure-ai-projects/api.metadata.yml index c92f33d84ddf..cd854aee232c 100644 --- a/sdk/ai/azure-ai-projects/api.metadata.yml +++ b/sdk/ai/azure-ai-projects/api.metadata.yml @@ -1,4 +1,4 @@ -apiMdSha256: ddeaf8129091538f23a0d1b125e3623689874d544603d86dabf1a78ca4469425 -packageVersion: 2.9.0 +apiHash: 4d010ad6620cc1bb8549f8521d271bd7641a19b194d9502ff3e233d95dd05f57 +packageVersion: 2.10.0 parserVersion: 0.3.31 -pythonVersion: 3.12.10 +pythonVersion: 3.12.4 diff --git a/sdk/ai/azure-ai-projects/apiview-properties.json b/sdk/ai/azure-ai-projects/apiview-properties.json index 1cf1d89c52d6..941d9bf2c1ce 100644 --- a/sdk/ai/azure-ai-projects/apiview-properties.json +++ b/sdk/ai/azure-ai-projects/apiview-properties.json @@ -167,7 +167,7 @@ "azure.ai.projects.models.CreateAsyncResponse": "Azure.AI.Projects.createAsync.Response.anonymous", "azure.ai.projects.models.CreateSkillVersionFromFilesBody": "Azure.AI.Projects.CreateSkillVersionFromFilesBody", "azure.ai.projects.models.CreateTelephonyBindingRequest": "Azure.AI.Projects.CreateTelephonyBindingRequest", - "azure.ai.projects.models.CreateTeamsPhoneExtensionTelephonyBindingRequest": "Azure.AI.Projects.CreateTeamsPhoneExtensionTelephonyBindingRequest", + "azure.ai.projects.models.CreateTeamsPhoneExtensibilityTelephonyBindingRequest": "Azure.AI.Projects.CreateTeamsPhoneExtensibilityTelephonyBindingRequest", "azure.ai.projects.models.CreateTelephonyCallJobRequest": "Azure.AI.Projects.CreateTelephonyCallJobRequest", "azure.ai.projects.models.CreateTranscriptionResponseJsonUsage": "OpenAI.CreateTranscriptionResponseJsonUsage", "azure.ai.projects.models.CreateTwilioTelephonyBindingRequest": "Azure.AI.Projects.CreateTwilioTelephonyBindingRequest", @@ -318,6 +318,10 @@ "azure.ai.projects.models.Microsoft365PermissionScopes": "Azure.AI.Projects.Microsoft365PermissionScopes", "azure.ai.projects.models.Microsoft365PublishDefaults": "Azure.AI.Projects.Microsoft365PublishDefaults", "azure.ai.projects.models.Microsoft365PublishResult": "Azure.AI.Projects.Microsoft365PublishResponse", + "azure.ai.projects.models.MicrosoftCopilotHarness": "Azure.AI.Projects.MicrosoftCopilotHarness", + "azure.ai.projects.models.MicrosoftCopilotToolsetConfig": "Azure.AI.Projects.MicrosoftCopilotToolsetConfig", + "azure.ai.projects.models.MicrosoftCopilotToolsetDefaultConfig": "Azure.AI.Projects.MicrosoftCopilotToolsetDefaultConfig", + "azure.ai.projects.models.MicrosoftCopilotToolsetPreview": "Azure.AI.Projects.MicrosoftCopilotToolsetPreview", "azure.ai.projects.models.MicrosoftFabricPreviewTool": "Azure.AI.Projects.MicrosoftFabricPreviewTool", "azure.ai.projects.models.MisalignmentErrorDetailsResource": "OpenAI.MisalignmentErrorDetailsResource", "azure.ai.projects.models.MisalignmentSteer": "OpenAI._MisalignmentSteer", @@ -502,9 +506,9 @@ "azure.ai.projects.models.TaxonomyCategory": "Azure.AI.Projects.TaxonomyCategory", "azure.ai.projects.models.TaxonomySubCategory": "Azure.AI.Projects.TaxonomySubCategory", "azure.ai.projects.models.TelephonyBinding": "Azure.AI.Projects.TelephonyBinding", - "azure.ai.projects.models.TeamsPhoneExtensionTelephonyBinding": "Azure.AI.Projects.TeamsPhoneExtensionTelephonyBinding", + "azure.ai.projects.models.TeamsPhoneExtensibilityTelephonyBinding": "Azure.AI.Projects.TeamsPhoneExtensibilityTelephonyBinding", "azure.ai.projects.models.TelephonyBindingListItem": "Azure.AI.Projects.TelephonyBindingListItem", - "azure.ai.projects.models.TeamsPhoneExtensionTelephonyBindingListItem": "Azure.AI.Projects.TeamsPhoneExtensionTelephonyBindingListItem", + "azure.ai.projects.models.TeamsPhoneExtensibilityTelephonyBindingListItem": "Azure.AI.Projects.TeamsPhoneExtensibilityTelephonyBindingListItem", "azure.ai.projects.models.TeamsTelephonyTransferDestination": "Azure.AI.Projects.TeamsTelephonyTransferDestination", "azure.ai.projects.models.TelemetryConfig": "Azure.AI.Projects.TelemetryConfig", "azure.ai.projects.models.TelephonyCallJob": "Azure.AI.Projects.TelephonyCallJob", @@ -609,7 +613,6 @@ "azure.ai.projects.models.VoiceAgentLlmInterimResponseConfig": "Azure.AI.Projects.VoiceAgentLlmInterimResponseConfig", "azure.ai.projects.models.VoiceAgentMcpTool": "Azure.AI.Projects.VoiceAgentMcpTool", "azure.ai.projects.models.VoiceAgentNoiseReduction": "Azure.AI.Projects.VoiceAgentNoiseReduction", - "azure.ai.projects.models.VoiceAgentRealtimeResponseBase": "Azure.AI.Projects.VoiceAgentRealtimeResponseBase", "azure.ai.projects.models.VoiceAgentRealtimeResponse": "Azure.AI.Projects.VoiceAgentRealtimeResponse", "azure.ai.projects.models.VoiceAgentResponseCreateParams": "Azure.AI.Projects.VoiceAgentResponseCreateParams", "azure.ai.projects.models.VoiceAgentRtcCallErrorDetails": "Azure.AI.Projects.VoiceAgentRtcCallErrorDetails", @@ -635,6 +638,10 @@ "azure.ai.projects.models.VoiceAgentSessionAvatarConfig": "Azure.AI.Projects.VoiceAgentSessionAvatarConfig", "azure.ai.projects.models.VoiceAgentSessionResponseConfig": "Azure.AI.Projects.VoiceAgentSessionResponseConfig", "azure.ai.projects.models.VoiceAgentSessionUpdateConfig": "Azure.AI.Projects.VoiceAgentSessionUpdateConfig", + "azure.ai.projects.models.VoiceAgentSoundscapeConfig": "Azure.AI.Projects.VoiceAgentSoundscapeConfig", + "azure.ai.projects.models.VoiceAgentSoundscapeSourceConfig": "Azure.AI.Projects.VoiceAgentSoundscapeSourceConfig", + "azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig": "Azure.AI.Projects.VoiceAgentSoundscapeTrackConfig", + "azure.ai.projects.models.VoiceAgentSoundscapeWorkingTrackConfig": "Azure.AI.Projects.VoiceAgentSoundscapeWorkingTrackConfig", "azure.ai.projects.models.VoiceAgentStaticInterimResponseConfig": "Azure.AI.Projects.VoiceAgentStaticInterimResponseConfig", "azure.ai.projects.models.VoiceAgentSubagent": "Azure.AI.Projects.VoiceAgentSubagent", "azure.ai.projects.models.VoiceAgentSubagentConfig": "Azure.AI.Projects.VoiceAgentSubagentConfig", @@ -650,7 +657,6 @@ "azure.ai.projects.models.VoiceHostedAgentConversationEngine": "Azure.AI.Projects.VoiceHostedAgentConversationEngine", "azure.ai.projects.models.VoiceRecording": "Azure.AI.Projects.VoiceRecording", "azure.ai.projects.models.VoiceRecordingChannelLayout": "Azure.AI.Projects.VoiceRecordingChannelLayout", - "azure.ai.projects.models.VoiceResponseBase": "Azure.AI.Projects.VoiceResponseBase", "azure.ai.projects.models.VoiceResponse": "Azure.AI.Projects.VoiceResponse", "azure.ai.projects.models.VoiceResponseAudio": "Azure.AI.Projects.VoiceResponseAudio", "azure.ai.projects.models.VoiceResponseAudioOutput": "Azure.AI.Projects.VoiceResponseAudioOutput", @@ -722,6 +728,7 @@ "azure.ai.projects.models.GitHubCopilotBuiltInTool": "Azure.AI.Projects.GitHubCopilotBuiltInTool", "azure.ai.projects.models.InputFidelity": "OpenAI.InputFidelity", "azure.ai.projects.models.ImageGenAction": "OpenAI.ImageGenActionEnum", + "azure.ai.projects.models.MicrosoftCopilotBuiltInTool": "Azure.AI.Projects.MicrosoftCopilotBuiltInTool", "azure.ai.projects.models.OpenApiAuthType": "Azure.AI.Projects.OpenApiAuthType", "azure.ai.projects.models.FunctionShellToolParamEnvironmentType": "OpenAI.FunctionShellToolParamEnvironmentType", "azure.ai.projects.models.ContainerSkillType": "OpenAI.ContainerSkillType", @@ -737,7 +744,9 @@ "azure.ai.projects.models.VoiceAgentEndOfUtteranceThresholdLevel": "Azure.AI.Projects.VoiceAgentEndOfUtteranceThresholdLevel", "azure.ai.projects.models.VoiceAgentEchoCancellationReferenceSource": "Azure.AI.Projects.VoiceAgentEchoCancellationReferenceSource", "azure.ai.projects.models.VoiceAgentInputTranscriptionModel": "Azure.AI.Projects.VoiceAgentInputTranscriptionModel", + "azure.ai.projects.models.VoiceAgentProfanityFilterMode": "Azure.AI.Projects.VoiceAgentProfanityFilterMode", "azure.ai.projects.models.VoiceAgentAudioTimestampType": "Azure.AI.Projects.VoiceAgentAudioTimestampType", + "azure.ai.projects.models.VoiceAgentSoundscapeSound": "Azure.AI.Projects.VoiceAgentSoundscapeSound", "azure.ai.projects.models.VoiceOutputModality": "Azure.AI.Projects.VoiceOutputModality", "azure.ai.projects.models.VoiceAgentSessionIncludeOption": "Azure.AI.Projects.VoiceAgentSessionIncludeOption", "azure.ai.projects.models.VoiceAgentInterimResponseTrigger": "Azure.AI.Projects.VoiceAgentInterimResponseTrigger", @@ -838,6 +847,7 @@ "azure.ai.projects.models.RealtimeServerEventType": "OpenAI.RealtimeServerEventType", "azure.ai.projects.models.CreateTranscriptionResponseJsonUsageType": "OpenAI.CreateTranscriptionResponseJsonUsageType", "azure.ai.projects.models.VoiceAgentSubagentAbortReason": "Azure.AI.Projects.VoiceAgentSubagentAbortReason", + "azure.ai.projects.models.VoiceAgentTransport": "Azure.AI.Projects.VoiceAgentTransport", "azure.ai.projects.operations.AgentsOperations.get": "Azure.AI.Projects.Agents.getAgent", "azure.ai.projects.aio.operations.AgentsOperations.get": "Azure.AI.Projects.Agents.getAgent", "azure.ai.projects.operations.AgentsOperations.delete": "Azure.AI.Projects.Agents.deleteAgent", @@ -995,5 +1005,5 @@ "azure.ai.projects.operations.ToolboxesOperations.delete_version": "Azure.AI.Projects.Toolboxes.deleteToolboxVersion", "azure.ai.projects.aio.operations.ToolboxesOperations.delete_version": "Azure.AI.Projects.Toolboxes.deleteToolboxVersion" }, - "CrossLanguageVersion": "668f4b97fbcf" + "CrossLanguageVersion": "1c8b2a2c820a" } \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index b25a6af5095d..967ce583e174 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_c1c72aea19" + "Tag": "python/ai/azure-ai-projects_5245c1d334" } diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py index 747660cf3f4f..94863905f720 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py @@ -510,4 +510,7 @@ def patch_sdk(): `patch_sdk` is a last resort escape hatch that allows you to do customizations you can't accomplish using the techniques described in https://aka.ms/azsdk/python/dpcodegen/python/customize + + :return: None + :rtype: None """ diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime.py index e3b40d5b3ff5..d492b10b4d01 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime.py @@ -43,6 +43,7 @@ from .models._patch import _FOUNDRY_FEATURES_HEADER_NAME, _has_header_case_insensitive from ._utils.model_base import Model as _Model, SdkJSONEncoder from ._version import VERSION +from ._realtime_utils import _normalize_transport, _without_transport_query _LOGGER = logging.getLogger("azure.ai.projects.realtime") @@ -696,6 +697,26 @@ class BetaRealtimeConnectionManager: # pylint: disable=too-many-instance-attrib Returned by :meth:`BetaRealtime.connect`; you normally use it as ``with client.beta.voice_agents.realtime.connect(...) as conn:``. + + :keyword str endpoint: The Foundry project endpoint. + :keyword credential: The credential used for the WebSocket handshake. + :paramtype credential: ~azure.core.credentials.TokenCredential + :keyword credential_scopes: The token scopes for the handshake. + :paramtype credential_scopes: list[str] + :keyword str api_version: The API version for the handshake. + :keyword str agent_name: The name of the voice agent. + :keyword agent_session_id: An optional session correlation identifier. + :paramtype agent_session_id: str or None + :keyword transport: The optional connection transport. The SDK encodes its wire value internally. + :paramtype transport: str or ~azure.ai.projects.models.VoiceAgentTransport or None + :keyword structured_inputs: Optional structured prompt inputs. + :paramtype structured_inputs: Mapping[str, Any] or None + :keyword connection_url: An optional WebSocket URL overriding the computed route. + :paramtype connection_url: str or None + :keyword extra_query: Additional handshake query parameters. + :paramtype extra_query: Mapping[str, str] or None + :keyword extra_headers: Additional handshake headers. + :paramtype extra_headers: Mapping[str, str] or None """ def __init__( # pylint: disable=too-many-arguments @@ -707,6 +728,7 @@ def __init__( # pylint: disable=too-many-arguments api_version: str, agent_name: str, agent_session_id: Optional[str] = None, + transport: Optional[Union[str, "_models.VoiceAgentTransport"]] = None, structured_inputs: Optional[Mapping[str, Any]] = None, connection_url: Optional[str] = None, extra_query: Optional[Mapping[str, str]] = None, @@ -719,6 +741,7 @@ def __init__( # pylint: disable=too-many-arguments self._api_version = api_version self._agent_name = agent_name self._agent_session_id = agent_session_id + self._transport = _normalize_transport(transport) self._structured_inputs = structured_inputs self._connection_url = connection_url self._extra_query = dict(extra_query or {}) @@ -752,6 +775,8 @@ def enter(self) -> BetaRealtimeConnection: # pylint: disable=too-many-locals if self._connection_url is not None: _assert_trusted_connection_url(self._connection_url, self._endpoint) url = self._connection_url or _to_ws_url(self._endpoint, self._agent_name) + if self._transport is not None: + url = _without_transport_query(url) if not url.startswith("wss://"): raise ValueError("The realtime WebSocket URL must use wss:// to protect credentials in transit.") @@ -764,6 +789,8 @@ def enter(self) -> BetaRealtimeConnection: # pylint: disable=too-many-locals # header -- it must be serialized and appended to the URL below, not sent as one. params["structured_input"] = json.dumps(self._structured_inputs, cls=SdkJSONEncoder) params.update(self._extra_query) + if self._transport is not None: + params["transport"] = self._transport if params: # Preserve an existing query string on a `connection_url` override (for example a @@ -858,6 +885,7 @@ def connect( # pylint: disable=too-many-arguments *, agent_name: str, agent_session_id: Optional[str] = None, + transport: Optional[Union[str, "_models.VoiceAgentTransport"]] = None, structured_inputs: Optional[Mapping[str, Any]] = None, connection_url: Optional[str] = None, api_version: Optional[str] = None, @@ -872,6 +900,12 @@ def connect( # pylint: disable=too-many-arguments :keyword agent_session_id: An optional identifier used to correlate the voice session. Default value is None. :paramtype agent_session_id: str or None + :keyword transport: The transport used for this connection. Known values are + ``VoiceAgentTransport.WEBSOCKET`` and ``VoiceAgentTransport.WEBRTC``. When omitted, the + service defaults to WebSocket. The SDK constructs the wire query internally; an explicit + selection overrides an existing transport query value. For WebRTC, the caller must + manage the peer connection, media, and SDP offer/answer exchange. + :paramtype transport: str or ~azure.ai.projects.models.VoiceAgentTransport or None :keyword structured_inputs: A mapping of structured-input names to their values for this session (see :attr:`~azure.ai.projects.models.CreateTelephonyCallJobRequest.structured_inputs` for the analogous shape used elsewhere). Serialized to JSON on the wire. Default value is @@ -894,6 +928,7 @@ def connect( # pylint: disable=too-many-arguments :paramtype extra_headers: Mapping[str, str] or None :return: A context manager yielding a :class:`BetaRealtimeConnection`. :rtype: ~azure.ai.projects.BetaRealtimeConnectionManager + :raises TypeError: If ``transport`` is not a string or VoiceAgentTransport. """ return BetaRealtimeConnectionManager( endpoint=self._config.endpoint, @@ -902,6 +937,7 @@ def connect( # pylint: disable=too-many-arguments api_version=api_version or self._config.api_version, agent_name=agent_name, agent_session_id=agent_session_id, + transport=transport, structured_inputs=structured_inputs, connection_url=connection_url, extra_query=extra_query, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime_utils.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime_utils.py new file mode 100644 index 000000000000..e80170d7b760 --- /dev/null +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_realtime_utils.py @@ -0,0 +1,40 @@ +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------- +"""Shared transport-option helpers for the handwritten realtime clients.""" + +from typing import Optional, Union +from urllib.parse import unquote_plus, urlsplit + +from .models import VoiceAgentTransport + + +def _normalize_transport(transport: Optional[Union[str, VoiceAgentTransport]]) -> Optional[str]: + """Normalize the typed transport option without restricting future service values. + + :param transport: The optional transport selector. + :type transport: str or ~azure.ai.projects.models.VoiceAgentTransport or None + :return: The wire value, or None when the option is omitted. + :rtype: str or None + :raises TypeError: If the selector is neither a string nor a transport enum. + """ + if transport is None: + return None + if not isinstance(transport, str): + raise TypeError("transport must be a string or VoiceAgentTransport.") + return transport.value if isinstance(transport, VoiceAgentTransport) else transport + + +def _without_transport_query(url: str) -> str: + """Remove transport parameters before applying an explicit typed selection. + + Preserve unrelated query encodings and repeated values. + + :param str url: The connection URL. + :return: The URL without existing transport parameters. + :rtype: str + """ + parsed = urlsplit(url) + query = "&".join(part for part in parsed.query.split("&") if unquote_plus(part.partition("=")[0]) != "transport") + return parsed._replace(query=query).geturl() diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/polling.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/polling.py new file mode 100644 index 000000000000..ee4515ebe881 --- /dev/null +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/polling.py @@ -0,0 +1,24 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Shared polling customizations.""" + +from azure.core.polling.base_polling import LROBasePolling +from azure.core.polling.async_base_polling import AsyncLROBasePolling + + +class AIProjectsLROBasePolling(LROBasePolling): + """Azure AI Projects polling that accepts both cancellation spellings.""" + + def status(self) -> str: + value = super().status() + return "canceled" if value.lower() == "cancelled" else value + + +class AsyncAIProjectsLROBasePolling(AsyncLROBasePolling): + """Async Azure AI Projects polling that accepts both cancellation spellings.""" + + def status(self) -> str: + value = super().status() + return "canceled" if value.lower() == "cancelled" else value diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_version.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_version.py index 01239942fb5c..8dbc8389f5a3 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_version.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_version.py @@ -6,4 +6,4 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- -VERSION = "2.9.0" +VERSION = "2.10.0" diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_realtime.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_realtime.py index 6b3ed006d7e4..67ffc9df304c 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_realtime.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_realtime.py @@ -57,6 +57,7 @@ from ..models._patch import _FOUNDRY_FEATURES_HEADER_NAME, _has_header_case_insensitive from .._utils.model_base import Model as _Model, SdkJSONEncoder from .._version import VERSION +from .._realtime_utils import _normalize_transport, _without_transport_query _LOGGER = logging.getLogger("azure.ai.projects.realtime") @@ -722,6 +723,26 @@ class AsyncBetaRealtimeConnectionManager: # pylint: disable=too-many-instance-a Returned by :meth:`AsyncBetaRealtime.connect`; you normally use it as ``async with client.beta.voice_agents.realtime.connect(...) as conn:``. + + :keyword str endpoint: The Foundry project endpoint. + :keyword credential: The credential used for the WebSocket handshake. + :paramtype credential: ~azure.core.credentials_async.AsyncTokenCredential + :keyword credential_scopes: The token scopes for the handshake. + :paramtype credential_scopes: list[str] + :keyword str api_version: The API version for the handshake. + :keyword str agent_name: The name of the voice agent. + :keyword agent_session_id: An optional session correlation identifier. + :paramtype agent_session_id: str or None + :keyword transport: The optional connection transport. The SDK encodes its wire value internally. + :paramtype transport: str or ~azure.ai.projects.models.VoiceAgentTransport or None + :keyword structured_inputs: Optional structured prompt inputs. + :paramtype structured_inputs: Mapping[str, Any] or None + :keyword connection_url: An optional WebSocket URL overriding the computed route. + :paramtype connection_url: str or None + :keyword extra_query: Additional handshake query parameters. + :paramtype extra_query: Mapping[str, str] or None + :keyword extra_headers: Additional handshake headers. + :paramtype extra_headers: Mapping[str, str] or None """ def __init__( # pylint: disable=too-many-arguments @@ -733,6 +754,7 @@ def __init__( # pylint: disable=too-many-arguments api_version: str, agent_name: str, agent_session_id: Optional[str] = None, + transport: Optional[Union[str, "_models.VoiceAgentTransport"]] = None, structured_inputs: Optional[Mapping[str, Any]] = None, connection_url: Optional[str] = None, extra_query: Optional[Mapping[str, str]] = None, @@ -745,6 +767,7 @@ def __init__( # pylint: disable=too-many-arguments self._api_version = api_version self._agent_name = agent_name self._agent_session_id = agent_session_id + self._transport = _normalize_transport(transport) self._structured_inputs = structured_inputs self._connection_url = connection_url self._extra_query = dict(extra_query or {}) @@ -777,6 +800,8 @@ async def enter(self) -> AsyncBetaRealtimeConnection: # pylint: disable=too-man if self._connection_url is not None: _assert_trusted_connection_url(self._connection_url, self._endpoint) url = self._connection_url or _to_ws_url(self._endpoint, self._agent_name) + if self._transport is not None: + url = _without_transport_query(url) if not url.startswith("wss://"): raise ValueError("The realtime WebSocket URL must use wss:// to protect credentials in transit.") @@ -789,6 +814,8 @@ async def enter(self) -> AsyncBetaRealtimeConnection: # pylint: disable=too-man # header -- aiohttp appends `params` to the URL for us below. params["structured_input"] = json.dumps(self._structured_inputs, cls=SdkJSONEncoder) params.update(self._extra_query) + if self._transport is not None: + params["transport"] = self._transport target_url = urlparse(url)._replace(query="", fragment="").geturl() _LOGGER.debug("WebSocket CONNECT target=%s", target_url) @@ -869,6 +896,7 @@ def connect( # pylint: disable=too-many-arguments *, agent_name: str, agent_session_id: Optional[str] = None, + transport: Optional[Union[str, "_models.VoiceAgentTransport"]] = None, structured_inputs: Optional[Mapping[str, Any]] = None, connection_url: Optional[str] = None, api_version: Optional[str] = None, @@ -883,6 +911,12 @@ def connect( # pylint: disable=too-many-arguments :keyword agent_session_id: An optional identifier used to correlate the voice session. Default value is None. :paramtype agent_session_id: str or None + :keyword transport: The transport used for this connection. Known values are + ``VoiceAgentTransport.WEBSOCKET`` and ``VoiceAgentTransport.WEBRTC``. When omitted, the + service defaults to WebSocket. The SDK constructs the wire query internally; an explicit + selection overrides an existing transport query value. For WebRTC, the caller must + manage the peer connection, media, and SDP offer/answer exchange. + :paramtype transport: str or ~azure.ai.projects.models.VoiceAgentTransport or None :keyword structured_inputs: A mapping of structured-input names to their values for this session (see :attr:`~azure.ai.projects.models.CreateTelephonyCallJobRequest.structured_inputs` for the analogous shape used elsewhere). Serialized to JSON on the wire. Default value is @@ -905,6 +939,7 @@ def connect( # pylint: disable=too-many-arguments :paramtype extra_headers: Mapping[str, str] or None :return: An async context manager yielding an :class:`AsyncBetaRealtimeConnection`. :rtype: ~azure.ai.projects.aio.AsyncBetaRealtimeConnectionManager + :raises TypeError: If ``transport`` is not a string or VoiceAgentTransport. """ return AsyncBetaRealtimeConnectionManager( endpoint=self._config.endpoint, @@ -913,6 +948,7 @@ def connect( # pylint: disable=too-many-arguments api_version=api_version or self._config.api_version, agent_name=agent_name, agent_session_id=agent_session_id, + transport=transport, structured_inputs=structured_inputs, connection_url=connection_url, extra_query=extra_query, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py index dd2a527484c0..a4c93a7c971c 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py @@ -18613,8 +18613,8 @@ def list_bindings( :param agent_name: The name of the voice agent whose bindings are listed. Required. :type agent_name: str - :keyword provider: Filters bindings by provider. Known values are: "teams_phone_extension" and - "twilio". Default value is None. + :keyword provider: Filters bindings by provider. Known values are: "teams_phone_extensibility" + and "twilio". Default value is None. :paramtype provider: str or ~azure.ai.projects.models.TelephonyProvider :keyword status: Filters bindings by lifecycle status. Known values are: "active" and "suspended". Default value is None. @@ -19075,7 +19075,7 @@ def list_calls( :param agent_name: The name of the voice agent whose calls are listed. Required. :type agent_name: str - :keyword provider: Filters calls by provider. Known values are: "teams_phone_extension" and + :keyword provider: Filters calls by provider. Known values are: "teams_phone_extensibility" and "twilio". Default value is None. :paramtype provider: str or ~azure.ai.projects.models.TelephonyProvider :keyword status: Filters calls by lifecycle status. Known values are: "in_progress", "success", diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agent_insights_async.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agent_insights_async.py index cd9a349c8c60..80f4fb033829 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agent_insights_async.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agent_insights_async.py @@ -12,7 +12,6 @@ from collections.abc import MutableMapping from azure.core.polling import AsyncNoPolling, AsyncPollingMethod -from azure.core.polling.async_base_polling import AsyncLROBasePolling from azure.core.tracing.decorator_async import distributed_trace_async from azure.core.utils import case_insensitive_dict @@ -21,18 +20,12 @@ ) from ... import models as _models from ..._utils.model_base import _deserialize +from ..._utils.polling import AsyncAIProjectsLROBasePolling from ...models import AsyncAgentInsightRunLROPoller JSON = MutableMapping[str, Any] -class _AsyncAgentInsightPolling(AsyncLROBasePolling): - def status(self) -> str: - status = super().status() - # Azure Core uses the single-L spelling for terminal cancellation. - return "canceled" if status.lower() == "cancelled" else status - - class BetaAgentInsightMonitorsOperations(BetaAgentInsightMonitorsOperationsGenerated): """Custom async operations for beta Agent Insights monitors.""" @@ -135,7 +128,7 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: AsyncPollingMethod = cast( AsyncPollingMethod, - _AsyncAgentInsightPolling( + AsyncAIProjectsLROBasePolling( lro_delay, path_format_arguments=path_format_arguments, headers=headers, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agents_async.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agents_async.py index 72e77494cbc4..79544eb50730 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agents_async.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_agents_async.py @@ -11,7 +11,6 @@ from typing import Union, Optional, Any, IO, cast, overload from azure.core.exceptions import HttpResponseError from azure.core.polling import AsyncNoPolling, AsyncPollingMethod -from azure.core.polling.async_base_polling import AsyncLROBasePolling from azure.core.tracing.decorator_async import distributed_trace_async from azure.core.utils import case_insensitive_dict from ._operations import ( @@ -22,6 +21,7 @@ ) from ... import models as _models from ..._utils.model_base import _deserialize +from ..._utils.polling import AsyncAIProjectsLROBasePolling from ...models import AsyncAgentOptimizationLROPoller from ...operations._patch_agents import _compute_sha256_from_stream from ...models._patch import ( @@ -416,7 +416,7 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: AsyncPollingMethod = cast( AsyncPollingMethod, - AsyncLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs), + AsyncAIProjectsLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs), ) elif polling is False: polling_method = cast(AsyncPollingMethod, AsyncNoPolling()) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_datasets_async.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_datasets_async.py index e784e99bb0f7..1be314c396fe 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_datasets_async.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_datasets_async.py @@ -18,13 +18,13 @@ from azure.storage.blob.aio import ContainerClient from azure.core.async_paging import AsyncItemPaged from azure.core.polling import AsyncNoPolling, AsyncPollingMethod -from azure.core.polling.async_base_polling import AsyncLROBasePolling from azure.core.tracing.decorator import distributed_trace from azure.core.tracing.decorator_async import distributed_trace_async from ._operations import DatasetsOperations as DatasetsOperationsGenerated from ... import models as _models from ..._utils.model_base import _deserialize +from ..._utils.polling import AsyncAIProjectsLROBasePolling from ...models import AsyncDatasetGenerationLROPoller from ...models._patch import ( _DATA_GENERATION_JOBS_FEATURE_HEADER, @@ -145,7 +145,7 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: AsyncPollingMethod = cast( AsyncPollingMethod, - AsyncLROBasePolling( + AsyncAIProjectsLROBasePolling( lro_delay, path_format_arguments=path_format_arguments, headers=polling_headers, **kwargs ), ) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_evaluators_async.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_evaluators_async.py index e19947280c5f..c7f3c68401ff 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_evaluators_async.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_evaluators_async.py @@ -9,13 +9,13 @@ from typing import Any, IO, Optional, Union, cast, overload from azure.core.polling import AsyncNoPolling, AsyncPollingMethod -from azure.core.polling.async_base_polling import AsyncLROBasePolling from azure.core.tracing.decorator_async import distributed_trace_async from azure.core.utils import case_insensitive_dict from ._operations import EvaluatorsOperations as EvaluatorsOperationsGenerated from ... import models as _models from ..._utils.model_base import _deserialize +from ..._utils.polling import AsyncAIProjectsLROBasePolling from ...models import AsyncEvaluatorGenerationLROPoller JSON = MutableMapping[str, Any] @@ -76,9 +76,7 @@ async def begin_create_generation_job( headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) params = kwargs.pop("params", {}) or {} - content_type: Optional[str] = kwargs.pop( - "content_type", headers.pop("Content-Type", None) - ) + content_type: Optional[str] = kwargs.pop("content_type", headers.pop("Content-Type", None)) cls = kwargs.pop("cls", None) polling: Union[bool, AsyncPollingMethod] = kwargs.pop("polling", True) lro_delay = kwargs.pop("polling_interval", self._config.polling_interval) @@ -103,29 +101,21 @@ def get_long_running_output(pipeline_response): response_headers["Operation-Location"] = self._deserialize( "str", response.headers.get("Operation-Location") ) - response_headers["Location"] = self._deserialize( - "str", response.headers.get("Location") - ) + response_headers["Location"] = self._deserialize("str", response.headers.get("Location")) - deserialized = _deserialize( - _models.EvaluatorVersion, response.json().get("result", {}) - ) + deserialized = _deserialize(_models.EvaluatorVersion, response.json().get("result", {})) if cls: return cls(pipeline_response, deserialized, response_headers) return deserialized path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), + "endpoint": self._serialize.url("self._config.endpoint", self._config.endpoint, "str", skip_quote=True), } if polling is True: polling_method: AsyncPollingMethod = cast( AsyncPollingMethod, - AsyncLROBasePolling( - lro_delay, path_format_arguments=path_format_arguments, **kwargs - ), + AsyncAIProjectsLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs), ) elif polling is False: polling_method = cast(AsyncPollingMethod, AsyncNoPolling()) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/__init__.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/__init__.py index 9dff953a213f..a170055a1338 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/__init__.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/__init__.py @@ -166,7 +166,7 @@ CosmosDBIndex, CreateAsyncResponse, CreateSkillVersionFromFilesBody, - CreateTeamsPhoneExtensionTelephonyBindingRequest, + CreateTeamsPhoneExtensibilityTelephonyBindingRequest, CreateTelephonyBindingRequest, CreateTelephonyCallJobRequest, CreateTranscriptionResponseJsonUsage, @@ -321,6 +321,10 @@ Microsoft365PermissionScopes, Microsoft365PublishDefaults, Microsoft365PublishResult, + MicrosoftCopilotHarness, + MicrosoftCopilotToolsetConfig, + MicrosoftCopilotToolsetDefaultConfig, + MicrosoftCopilotToolsetPreview, MicrosoftFabricPreviewTool, MisalignmentErrorDetailsResource, MisalignmentSteer, @@ -507,8 +511,8 @@ SupervisedFineTuningDataGenerationJobOutputConfiguration, TaxonomyCategory, TaxonomySubCategory, - TeamsPhoneExtensionTelephonyBinding, - TeamsPhoneExtensionTelephonyBindingListItem, + TeamsPhoneExtensibilityTelephonyBinding, + TeamsPhoneExtensibilityTelephonyBindingListItem, TeamsTelephonyTransferDestination, TelemetryConfig, TelemetryEndpoint, @@ -621,7 +625,6 @@ VoiceAgentMcpTool, VoiceAgentNoiseReduction, VoiceAgentRealtimeResponse, - VoiceAgentRealtimeResponseBase, VoiceAgentResponseCreateParams, VoiceAgentRtcCallErrorDetails, VoiceAgentSemanticVadTurnDetection, @@ -646,6 +649,10 @@ VoiceAgentSessionAvatarConfig, VoiceAgentSessionResponseConfig, VoiceAgentSessionUpdateConfig, + VoiceAgentSoundscapeConfig, + VoiceAgentSoundscapeSourceConfig, + VoiceAgentSoundscapeTrackConfig, + VoiceAgentSoundscapeWorkingTrackConfig, VoiceAgentStaticInterimResponseConfig, VoiceAgentSubagent, VoiceAgentSubagentConfig, @@ -667,7 +674,6 @@ VoiceResponse, VoiceResponseAudio, VoiceResponseAudioOutput, - VoiceResponseBase, WebIQPreviewTool, WebIQPreviewToolboxTool, WebSearchApproximateLocation, @@ -762,6 +768,7 @@ MemoryStoreObjectType, MemoryStoreUpdateStatus, Microsoft365PublishScope, + MicrosoftCopilotBuiltInTool, MisalignmentErrorType, OpenApiAuthType, OperationState, @@ -843,10 +850,13 @@ VoiceAgentInputTranscriptionModel, VoiceAgentInterimResponseTrigger, VoiceAgentNoiseReductionType, + VoiceAgentProfanityFilterMode, VoiceAgentSessionIncludeOption, + VoiceAgentSoundscapeSound, VoiceAgentSubagentAbortReason, VoiceAgentSystemToolName, VoiceAgentToolResponseScheduling, + VoiceAgentTransport, VoiceAgentTurnDetectionType, VoiceAudioCodec, VoiceAudioContainerFormat, @@ -1012,7 +1022,7 @@ "CosmosDBIndex", "CreateAsyncResponse", "CreateSkillVersionFromFilesBody", - "CreateTeamsPhoneExtensionTelephonyBindingRequest", + "CreateTeamsPhoneExtensibilityTelephonyBindingRequest", "CreateTelephonyBindingRequest", "CreateTelephonyCallJobRequest", "CreateTranscriptionResponseJsonUsage", @@ -1167,6 +1177,10 @@ "Microsoft365PermissionScopes", "Microsoft365PublishDefaults", "Microsoft365PublishResult", + "MicrosoftCopilotHarness", + "MicrosoftCopilotToolsetConfig", + "MicrosoftCopilotToolsetDefaultConfig", + "MicrosoftCopilotToolsetPreview", "MicrosoftFabricPreviewTool", "MisalignmentErrorDetailsResource", "MisalignmentSteer", @@ -1353,8 +1367,8 @@ "SupervisedFineTuningDataGenerationJobOutputConfiguration", "TaxonomyCategory", "TaxonomySubCategory", - "TeamsPhoneExtensionTelephonyBinding", - "TeamsPhoneExtensionTelephonyBindingListItem", + "TeamsPhoneExtensibilityTelephonyBinding", + "TeamsPhoneExtensibilityTelephonyBindingListItem", "TeamsTelephonyTransferDestination", "TelemetryConfig", "TelemetryEndpoint", @@ -1467,7 +1481,6 @@ "VoiceAgentMcpTool", "VoiceAgentNoiseReduction", "VoiceAgentRealtimeResponse", - "VoiceAgentRealtimeResponseBase", "VoiceAgentResponseCreateParams", "VoiceAgentRtcCallErrorDetails", "VoiceAgentSemanticVadTurnDetection", @@ -1492,6 +1505,10 @@ "VoiceAgentSessionAvatarConfig", "VoiceAgentSessionResponseConfig", "VoiceAgentSessionUpdateConfig", + "VoiceAgentSoundscapeConfig", + "VoiceAgentSoundscapeSourceConfig", + "VoiceAgentSoundscapeTrackConfig", + "VoiceAgentSoundscapeWorkingTrackConfig", "VoiceAgentStaticInterimResponseConfig", "VoiceAgentSubagent", "VoiceAgentSubagentConfig", @@ -1513,7 +1530,6 @@ "VoiceResponse", "VoiceResponseAudio", "VoiceResponseAudioOutput", - "VoiceResponseBase", "WebIQPreviewTool", "WebIQPreviewToolboxTool", "WebSearchApproximateLocation", @@ -1605,6 +1621,7 @@ "MemoryStoreObjectType", "MemoryStoreUpdateStatus", "Microsoft365PublishScope", + "MicrosoftCopilotBuiltInTool", "MisalignmentErrorType", "OpenApiAuthType", "OperationState", @@ -1686,10 +1703,13 @@ "VoiceAgentInputTranscriptionModel", "VoiceAgentInterimResponseTrigger", "VoiceAgentNoiseReductionType", + "VoiceAgentProfanityFilterMode", "VoiceAgentSessionIncludeOption", + "VoiceAgentSoundscapeSound", "VoiceAgentSubagentAbortReason", "VoiceAgentSystemToolName", "VoiceAgentToolResponseScheduling", + "VoiceAgentTransport", "VoiceAgentTurnDetectionType", "VoiceAudioCodec", "VoiceAudioContainerFormat", diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py index 4e7fc04e63ff..6590dd28ec1b 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py @@ -28,6 +28,8 @@ class _AgentDefinitionOptInKeys(str, Enum, metaclass=CaseInsensitiveEnumMeta): """GITHUB_COPILOT_V1_PREVIEW.""" SKILLS_V1_PREVIEW = "Skills=V1Preview" """SKILLS_V1_PREVIEW.""" + MICROSOFT_COPILOT_V1_PREVIEW = "MicrosoftCopilot=V1Preview" + """The Microsoft Copilot V1 preview feature opt-in.""" class _FoundryFeaturesOptInKeys(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -55,6 +57,8 @@ class _FoundryFeaturesOptInKeys(str, Enum, metaclass=CaseInsensitiveEnumMeta): """MODELS_V1_PREVIEW.""" MODEL_ROUTER_CONTROLS_V1_PREVIEW = "ModelRouterControls=V1Preview" """MODEL_ROUTER_CONTROLS_V1_PREVIEW.""" + FINETUNING_SESSIONS_V1_PREVIEW = "FineTuningSessions=V1Preview" + """FINETUNING_SESSIONS_V1_PREVIEW.""" class A2AProtocolVersion(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -142,6 +146,9 @@ class AgentEndpointProtocol(str, Enum, metaclass=CaseInsensitiveEnumMeta): """VOICE.""" INVOCATIONS_WS = "invocations_ws" """WebSocket-based protocol for hosted voice and real-time streaming agents.""" + VOICE_CONVERSATION_ENGINE = "voice_conversation_engine" + """Text conversation protocol for a hosted agent used by Voice Live Bridge. Declare version + ``1.0.0``; audio transport remains outside this protocol.""" class AgentIdentityStatus(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -899,7 +906,9 @@ class GenerationWarningType(str, Enum, metaclass=CaseInsensitiveEnumMeta): class GitHubCopilotBuiltInTool(str, Enum, metaclass=CaseInsensitiveEnumMeta): - """A customer-configurable GitHub Copilot built-in tool.""" + """The legacy GitHub Copilot built-in tool names retained for compatibility. Use + ``MicrosoftCopilotBuiltInTool`` for new integrations. + """ FILESYSTEM_READ = "filesystem_read" """Read and search files in the harness workspace.""" @@ -1060,6 +1069,21 @@ class Microsoft365PublishScope(str, Enum, metaclass=CaseInsensitiveEnumMeta): """Publish the app tenant-wide.""" +class MicrosoftCopilotBuiltInTool(str, Enum, metaclass=CaseInsensitiveEnumMeta): + """A customer-configurable Microsoft Copilot built-in tool.""" + + FILESYSTEM_READ = "filesystem_read" + """Read and search files in the harness workspace.""" + FILESYSTEM_WRITE = "filesystem_write" + """Create and modify files in the harness workspace.""" + SHELL = "shell" + """Execute operating-system commands.""" + WEB = "web" + """Fetch content and search external network resources.""" + SUBAGENTS = "subagents" + """Delegate work to additional agents.""" + + class MisalignmentErrorType(str, Enum, metaclass=CaseInsensitiveEnumMeta): """Type of MisalignmentErrorType.""" @@ -1115,9 +1139,9 @@ class PageOrder(str, Enum, metaclass=CaseInsensitiveEnumMeta): """Type of PageOrder.""" ASC = "asc" - """ASC.""" + """Return items in ascending creation-time order.""" DESC = "desc" - """DESC.""" + """Return items in descending creation-time order.""" class PendingUploadType(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -2010,8 +2034,8 @@ class TelephonyCallLifecycleEventSource(str, Enum, metaclass=CaseInsensitiveEnum GATEWAY = "gateway" """The Foundry telephony gateway supplied the observation.""" - TEAMS_PHONE_EXTENSION = "teams_phone_extension" - """Microsoft Teams Phone Extension supplied the observation.""" + TEAMS_PHONE_EXTENSIBILITY = "teams_phone_extensibility" + """Microsoft Teams Phone extensibility supplied the observation.""" TWILIO = "twilio" """Twilio supplied the observation.""" VOICE_AGENT = "voice_agent" @@ -2115,8 +2139,8 @@ class TelephonyProvider(str, Enum, metaclass=CaseInsensitiveEnumMeta): may be added over time. """ - TEAMS_PHONE_EXTENSION = "teams_phone_extension" - """Microsoft Teams Phone Extension.""" + TEAMS_PHONE_EXTENSIBILITY = "teams_phone_extensibility" + """Microsoft Teams Phone extensibility.""" TWILIO = "twilio" """Twilio Programmable Voice.""" @@ -2310,6 +2334,8 @@ class ToolType(str, Enum, metaclass=CaseInsensitiveEnumMeta): """CAPTURE_STRUCTURED_OUTPUTS.""" OPENAPI = "openapi" """OPENAPI.""" + MICROSOFT_COPILOT_TOOLSET_PREVIEW = "microsoft_copilot_toolset_preview" + """MICROSOFT_COPILOT_TOOLSET_PREVIEW.""" class TreatmentEffectType(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -2443,7 +2469,10 @@ class VoiceAgentEndOfUtteranceThresholdLevel(str, Enum, metaclass=CaseInsensitiv class VoiceAgentInputTranscriptionModel(str, Enum, metaclass=CaseInsensitiveEnumMeta): """The input-audio transcription model identifier. This is a model name, not a Foundry deployment name. Mirrors the transcription models supported by the managed voice backend, covering the - OpenAI Realtime transcription models plus the Azure and MAI models. Additional values may be + OpenAI Realtime transcription models plus the Azure and MAI models. ``azure-speech-streaming`` + is supported for cascaded text-model conversations only (not realtime speech-to-speech models), + requires a nonempty ``language`` such as ``en-US`` (comma-separated locales are supported), and + supports ``custom_speech``, ``phrase_list``, and ``profanity_filter``. Additional values may be added over time. """ @@ -2465,6 +2494,11 @@ class VoiceAgentInputTranscriptionModel(str, Enum, metaclass=CaseInsensitiveEnum """MAI transcription.""" AZURE_SPEECH = "azure-speech" """Azure AI Speech to text.""" + AZURE_SPEECH_STREAMING = "azure-speech-streaming" + """Azure AI Speech streaming transcription for cascaded text-model conversations only. Requires a + nonempty ``language``, such as ``en-US``; comma-separated locales are supported. Supports + ``custom_speech``, ``phrase_list``, and ``profanity_filter``. Not supported with realtime + speech-to-speech models.""" class VoiceAgentInterimResponseTrigger(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -2477,7 +2511,15 @@ class VoiceAgentInterimResponseTrigger(str, Enum, metaclass=CaseInsensitiveEnumM class VoiceAgentNoiseReductionType(str, Enum, metaclass=CaseInsensitiveEnumMeta): - """The input audio noise reduction mode.""" + """The input audio noise reduction mode. + + * `azure_deep_noise_suppression_headset`: Azure headset-optimized noise suppression for local voice activity + detection. Optimized for low-sample-rate audio, including 8 kHz telephony audio. Enhances detection audio without + changing the audio used for recognition. The mode is not restricted to headset devices. + * `azure_personal_noise_suppression`: Azure personalized noise suppression for local voice activity detection. + Starts with headset suppression and personalizes detection after automatic speaker enrollment. Recognition audio + is unchanged; headset suppression remains active when personalization is unavailable. + """ NEAR_FIELD = "near_field" """NEAR_FIELD.""" @@ -2485,6 +2527,25 @@ class VoiceAgentNoiseReductionType(str, Enum, metaclass=CaseInsensitiveEnumMeta) """FAR_FIELD.""" AZURE_DEEP_NOISE_SUPPRESSION = "azure_deep_noise_suppression" """Azure deep noise suppression.""" + AZURE_DEEP_NOISE_SUPPRESSION_HEADSET = "azure_deep_noise_suppression_headset" + """Azure headset-optimized noise suppression for local voice activity detection. Optimized for + low-sample-rate audio, including 8 kHz telephony audio. Enhances detection audio without + changing the audio used for recognition. The mode is not restricted to headset devices.""" + AZURE_PERSONAL_NOISE_SUPPRESSION = "azure_personal_noise_suppression" + """Azure personalized noise suppression for local voice activity detection. Starts with headset + suppression and personalizes detection after automatic speaker enrollment. Recognition audio is + unchanged; headset suppression remains active when personalization is unavailable.""" + + +class VoiceAgentProfanityFilterMode(str, Enum, metaclass=CaseInsensitiveEnumMeta): + """The supported input-transcript profanity filtering modes.""" + + MASKED = "masked" + """Masks recognized profanity in the transcript.""" + UNFILTERED = "unfiltered" + """Includes recognized profanity in the transcript, subject to mandatory service masking policies.""" + REMOVED = "removed" + """Removes recognized profanity from the transcript.""" class VoiceAgentSessionIncludeOption(str, Enum, metaclass=CaseInsensitiveEnumMeta): @@ -2498,6 +2559,25 @@ class VoiceAgentSessionIncludeOption(str, Enum, metaclass=CaseInsensitiveEnumMet """FILE_SEARCH_CALL_RESULTS.""" +class VoiceAgentSoundscapeSound(str, Enum, metaclass=CaseInsensitiveEnumMeta): + """The packaged sounds accepted by a built-in soundscape source.""" + + OFFICE = "office" + """Quiet office ambience.""" + CALL_CENTER = "call_center" + """Busy call-center ambience.""" + BUSY_STREET = "busy_street" + """Busy street ambience.""" + PEOPLE_TALKING = "people_talking" + """Crowd conversation ambience.""" + STREET_AND_METRO_STATION = "street_and_metro_station" + """Street and metro-station ambience.""" + DING1 = "ding1" + """The first notification tone.""" + DING2 = "ding2" + """The second notification tone.""" + + class VoiceAgentSubagentAbortReason(str, Enum, metaclass=CaseInsensitiveEnumMeta): """The reason a subagent consultation was aborted.""" @@ -2537,6 +2617,15 @@ class VoiceAgentToolResponseScheduling(str, Enum, metaclass=CaseInsensitiveEnumM """Create a follow-up response only when no response is active.""" +class VoiceAgentTransport(str, Enum, metaclass=CaseInsensitiveEnumMeta): + """The transport used for a voice-agent connection.""" + + WEBSOCKET = "websocket" + """Signaling and audio are exchanged as JSON events over the WebSocket. This is the default.""" + WEBRTC = "webrtc" + """WebRTC: the WebSocket carries only SDP signaling; media and the data channel are peer-to-peer.""" + + class VoiceAgentTurnDetectionType(str, Enum, metaclass=CaseInsensitiveEnumMeta): """The turn-detection strategy. Additional values may be added over time.""" diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py index f16784598ce7..fca3df7ac84d 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py @@ -179,8 +179,8 @@ class Tool(_Model): # pylint: disable=docstring-keyword-should-match-keyword-on BrowserAutomationPreviewTool, CaptureStructuredOutputsTool, CodeInterpreterTool, ComputerTool, ComputerUsePreviewTool, CustomToolParam, MicrosoftFabricPreviewTool, FabricIQPreviewTool, FileSearchTool, FunctionTool, GitHubCopilotToolsetPreview, ImageGenTool, LocalShellToolParam, - MCPTool, MemorySearchPreviewTool, NamespaceToolParam, OpenApiTool, - ProgrammaticToolCallingParam, SharepointPreviewTool, FunctionShellToolParam, + MCPTool, MemorySearchPreviewTool, MicrosoftCopilotToolsetPreview, NamespaceToolParam, + OpenApiTool, ProgrammaticToolCallingParam, SharepointPreviewTool, FunctionShellToolParam, ToolSearchToolParam, WebIQPreviewTool, WebSearchTool, WebSearchPreviewTool, WorkIQPreviewTool :ivar type: Required. Known values are: "function", "file_search", "computer", @@ -190,7 +190,8 @@ class Tool(_Model): # pylint: disable=docstring-keyword-should-match-keyword-on "browser_automation_preview", "fabric_dataagent_preview", "sharepoint_grounding_preview", "memory_search_preview", "work_iq_preview", "fabric_iq_preview", "toolbox_search_preview", "web_iq_preview", "github_copilot_toolset_preview", "a2a", "azure_ai_search", "azure_function", - "bing_grounding", "browser_automation", "capture_structured_outputs", and "openapi". + "bing_grounding", "browser_automation", "capture_structured_outputs", "openapi", and + "microsoft_copilot_toolset_preview". :vartype type: str or ~azure.ai.projects.models.ToolType """ @@ -204,7 +205,8 @@ class Tool(_Model): # pylint: disable=docstring-keyword-should-match-keyword-on \"sharepoint_grounding_preview\", \"memory_search_preview\", \"work_iq_preview\", \"fabric_iq_preview\", \"toolbox_search_preview\", \"web_iq_preview\", \"github_copilot_toolset_preview\", \"a2a\", \"azure_ai_search\", \"azure_function\", - \"bing_grounding\", \"browser_automation\", \"capture_structured_outputs\", and \"openapi\".""" + \"bing_grounding\", \"browser_automation\", \"capture_structured_outputs\", \"openapi\", and + \"microsoft_copilot_toolset_preview\".""" @overload def __init__( @@ -1276,7 +1278,7 @@ class AgentHarness(_Model): # pylint: disable=docstring-keyword-should-match-ke """A managed runtime and agent loop used to execute a prompt agent. You probably want to use the sub-classes and not this class directly. Known sub-classes are: - GitHubCopilotHarness + GitHubCopilotHarness, MicrosoftCopilotHarness :ivar type: The type of managed harness. Required. Default value is None. :vartype type: str @@ -4675,11 +4677,11 @@ class ApiError(_Model): # pylint: disable=docstring-keyword-should-match-keywor :vartype type: str :ivar misalignment: :vartype misalignment: ~azure.ai.projects.models.MisalignmentErrorDetailsResource - :ivar details: + :ivar details: Additional errors that contributed to this failure. :vartype details: list[~azure.ai.projects.models.ApiError] - :ivar additional_info: + :ivar additional_info: Additional structured information about the failure. :vartype additional_info: dict[str, any] - :ivar debug_info: + :ivar debug_info: Diagnostic information supplied by the service for troubleshooting. :vartype debug_info: dict[str, any] """ @@ -4693,12 +4695,15 @@ class ApiError(_Model): # pylint: disable=docstring-keyword-should-match-keywor visibility=["read", "create", "update", "delete", "query"] ) details: Optional[list["_models.ApiError"]] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Additional errors that contributed to this failure.""" additional_info: Optional[dict[str, Any]] = rest_field( name="additionalInfo", visibility=["read", "create", "update", "delete", "query"] ) + """Additional structured information about the failure.""" debug_info: Optional[dict[str, Any]] = rest_field( name="debugInfo", visibility=["read", "create", "update", "delete", "query"] ) + """Diagnostic information supplied by the service for troubleshooting.""" @overload def __init__( @@ -6027,6 +6032,9 @@ class BrowserAutomationPreviewTool( Tool, discriminator="browser_automation_preview" ): # pylint: disable=docstring-keyword-should-match-keyword-only """The input definition information for a Browser Automation Tool, as used to configure an Agent. + BrowserAutomationPreviewTool is deprecated. Please use BrowserAutomationTool instead. See + `https://aka.ms/bat/docs `_ for more information on Browser automation + tool. :ivar type: The object type, which is always 'browser_automation_preview'. Required. BROWSER_AUTOMATION_PREVIEW. @@ -6065,7 +6073,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class BrowserAutomationPreviewToolboxTool( ToolboxTool, discriminator="browser_automation_preview" ): # pylint: disable=docstring-keyword-should-match-keyword-only - """A browser automation tool stored in a toolbox. + """A browser automation tool stored in a toolbox. BrowserAutomationPreviewToolboxTool is + deprecated. Please use BrowserAutomationToolboxTool instead. See `https://aka.ms/bat/docs + `_ for more information on Browser automation tool. :ivar name: Optional user-defined name for this tool or configuration. :vartype name: str @@ -7679,10 +7689,10 @@ class CreateTelephonyBindingRequest(_Model): # pylint: disable=docstring-keywor """The request to create a telephony binding. You probably want to use the sub-classes and not this class directly. Known sub-classes are: - CreateTeamsPhoneExtensionTelephonyBindingRequest, CreateTwilioTelephonyBindingRequest + CreateTeamsPhoneExtensibilityTelephonyBindingRequest, CreateTwilioTelephonyBindingRequest - :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extension" and - "twilio". + :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extensibility" + and "twilio". :vartype provider: str or ~azure.ai.projects.models.TelephonyProvider :ivar connection_name: The Foundry connection name for the telephony provider. Required. :vartype connection_name: str @@ -7692,7 +7702,8 @@ class CreateTelephonyBindingRequest(_Model): # pylint: disable=docstring-keywor __mapping__: dict[str, _Model] = {} provider: str = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) - """The telephony provider. Required. Known values are: \"teams_phone_extension\" and \"twilio\".""" + """The telephony provider. Required. Known values are: \"teams_phone_extensibility\" and + \"twilio\".""" connection_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The Foundry connection name for the telephony provider. Required.""" label: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -7718,18 +7729,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class CreateTeamsPhoneExtensionTelephonyBindingRequest( - CreateTelephonyBindingRequest, discriminator="teams_phone_extension" +class CreateTeamsPhoneExtensibilityTelephonyBindingRequest( + CreateTelephonyBindingRequest, discriminator="teams_phone_extensibility" ): # pylint: disable=name-too-long,docstring-keyword-should-match-keyword-only - """The request to create a Microsoft Teams Phone Extension binding. + """The request to create a Microsoft Teams Phone extensibility binding. :ivar connection_name: The Foundry connection name for the telephony provider. Required. :vartype connection_name: str :ivar label: An optional display label for the binding. :vartype label: str - :ivar provider: The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone - Extension. - :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSION + :ivar provider: The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams + Phone extensibility. + :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSIBILITY :ivar phone_number: The optional display phone number for the Teams resource account. :vartype phone_number: str :ivar resource_account_object_id: The Microsoft Teams resource-account object identifier as a @@ -7737,8 +7748,9 @@ class CreateTeamsPhoneExtensionTelephonyBindingRequest( :vartype resource_account_object_id: str """ - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore - """The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone Extension.""" + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore + """The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams Phone + extensibility.""" phone_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The optional display phone number for the Teams resource account.""" resource_account_object_id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -7763,7 +7775,7 @@ def __init__(self, mapping: Mapping[str, Any]) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSION # type: ignore + self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY # type: ignore class CreateTelephonyCallJobRequest(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only @@ -7772,13 +7784,13 @@ class CreateTelephonyCallJobRequest(_Model): # pylint: disable=docstring-keywor :ivar destination: The phone destination to call. Required. :vartype destination: ~azure.ai.projects.models.TelephonyOutboundDestination :ivar connection_name: The Foundry connection name in the current project used to originate the - call. Its category selects Twilio or Azure Communication Services / Teams Phone Extension. No - inbound telephony binding is required. Required. + call. Its category selects Twilio or Azure Communication Services / Teams Phone extensibility. + No inbound telephony binding is required. Required. :vartype connection_name: str :ivar source: The caller identity used to originate the call. For a Twilio connection, provide - an authorized E.164 phone number. For an Azure Communication Services / Teams Phone Extension - connection, provide the Teams Resource Account object ID. The identity type is inferred from - the connection category; originating does not change inbound routing. Required. + an authorized E.164 phone number. For an Azure Communication Services / Teams Phone + extensibility connection, provide the Teams Resource Account object ID. The identity type is + inferred from the connection category; originating does not change inbound routing. Required. :vartype source: str :ivar purpose: An optional customer-declared purpose for placing the call. :vartype purpose: str @@ -7800,11 +7812,11 @@ class CreateTelephonyCallJobRequest(_Model): # pylint: disable=docstring-keywor """The phone destination to call. Required.""" connection_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The Foundry connection name in the current project used to originate the call. Its category - selects Twilio or Azure Communication Services / Teams Phone Extension. No inbound telephony - binding is required. Required.""" + selects Twilio or Azure Communication Services / Teams Phone extensibility. No inbound + telephony binding is required. Required.""" source: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The caller identity used to originate the call. For a Twilio connection, provide an authorized - E.164 phone number. For an Azure Communication Services / Teams Phone Extension connection, + E.164 phone number. For an Azure Communication Services / Teams Phone extensibility connection, provide the Teams Resource Account object ID. The identity type is inferred from the connection category; originating does not change inbound routing. Required.""" purpose: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -12062,15 +12074,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class GitHubCopilotHarness(AgentHarness, discriminator="github_copilot_preview"): - """The GitHub Copilot managed harness for prompt agents. + """The legacy GitHub Copilot managed harness retained for compatibility. Use + ``MicrosoftCopilotHarness`` for new prompt agents. - :ivar type: The type of managed harness. Always ``github_copilot_preview``. Required. Default - value is "github_copilot_preview". + :ivar type: The legacy type of managed harness. Always ``github_copilot_preview``; use + ``microsoft_copilot_preview`` for new prompt agents. Required. Default value is + "github_copilot_preview". :vartype type: str """ type: Literal["github_copilot_preview"] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore - """The type of managed harness. Always ``github_copilot_preview``. Required. Default value is + """The legacy type of managed harness. Always ``github_copilot_preview``; use + ``microsoft_copilot_preview`` for new prompt agents. Required. Default value is \"github_copilot_preview\".""" @overload @@ -12091,7 +12106,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class GitHubCopilotToolsetConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """An enablement override for a GitHub Copilot built-in tool. + """The legacy per-tool enablement override retained for compatibility. Use + ``MicrosoftCopilotToolsetConfig`` for new integrations. :ivar name: The built-in tool to configure. Required. Known values are: "filesystem_read", "filesystem_write", "shell", "web", and "subagents". @@ -12128,7 +12144,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class GitHubCopilotToolsetDefaultConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """The default enablement setting for GitHub Copilot built-in tools. + """The legacy default enablement setting retained for compatibility. Use + ``MicrosoftCopilotToolsetDefaultConfig`` for new integrations. :ivar enabled: Whether built-in tools are enabled by default. Defaults to true. :vartype enabled: bool @@ -12158,9 +12175,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class GitHubCopilotToolsetPreview( Tool, discriminator="github_copilot_toolset_preview" ): # pylint: disable=docstring-keyword-should-match-keyword-only - """Configuration overrides for GitHub Copilot built-in tools. + """The legacy GitHub Copilot toolset retained for compatibility. Use + ``MicrosoftCopilotToolsetPreview`` for new integrations. - :ivar type: The type of the toolset. Always ``github_copilot_toolset_preview``. Required. + :ivar type: The legacy type of the toolset. Always ``github_copilot_toolset_preview``; use + ``microsoft_copilot_toolset_preview`` for new integrations. Required. GITHUB_COPILOT_TOOLSET_PREVIEW. :vartype type: str or ~azure.ai.projects.models.GITHUB_COPILOT_TOOLSET_PREVIEW :ivar default_config: The default configuration for built-in tools. If omitted, built-in tools @@ -12171,7 +12190,8 @@ class GitHubCopilotToolsetPreview( """ type: Literal[ToolType.GITHUB_COPILOT_TOOLSET_PREVIEW] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore - """The type of the toolset. Always ``github_copilot_toolset_preview``. Required. + """The legacy type of the toolset. Always ``github_copilot_toolset_preview``; use + ``microsoft_copilot_toolset_preview`` for new integrations. Required. GITHUB_COPILOT_TOOLSET_PREVIEW.""" default_config: Optional["_models.GitHubCopilotToolsetDefaultConfig"] = rest_field( visibility=["read", "create", "update", "delete", "query"] @@ -12372,6 +12392,10 @@ class HostedAgentDefinition( :ivar session_configuration: Optional session defaults (for example, the idle timeout) applied to sessions created for this agent version. :vartype session_configuration: ~azure.ai.projects.models.SessionConfiguration + :ivar enable_memory_snapshot: Whether this agent version uses memory snapshots. Sessions for a + snapshot-enabled version start from a snapshot of the agent's initialized process rather than + starting the process from its image. Defaults to false. + :vartype enable_memory_snapshot: bool """ kind: Literal[AgentKind.HOSTED] = rest_discriminator(name="kind", visibility=["read", "create", "update", "delete", "query"]) # type: ignore @@ -12408,6 +12432,10 @@ class HostedAgentDefinition( ) """Optional session defaults (for example, the idle timeout) applied to sessions created for this agent version.""" + enable_memory_snapshot: Optional[bool] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Whether this agent version uses memory snapshots. Sessions for a snapshot-enabled version start + from a snapshot of the agent's initialized process rather than starting the process from its + image. Defaults to false.""" @overload def __init__( @@ -12422,6 +12450,7 @@ def __init__( code_configuration: Optional["_models.CodeConfiguration"] = None, telemetry_config: Optional["_models.TelemetryConfig"] = None, session_configuration: Optional["_models.SessionConfiguration"] = None, + enable_memory_snapshot: Optional[bool] = None, ) -> None: ... @overload @@ -14899,6 +14928,148 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) +class MicrosoftCopilotHarness(AgentHarness, discriminator="microsoft_copilot_preview"): + """The Microsoft Copilot managed harness for prompt agents. + + :ivar type: The type of managed harness. Always ``microsoft_copilot_preview``. Required. + Default value is "microsoft_copilot_preview". + :vartype type: str + """ + + type: Literal["microsoft_copilot_preview"] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore + """The type of managed harness. Always ``microsoft_copilot_preview``. Required. Default value is + \"microsoft_copilot_preview\".""" + + @overload + def __init__( + self, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.type = "microsoft_copilot_preview" # type: ignore + + +class MicrosoftCopilotToolsetConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """An enablement override for a Microsoft Copilot built-in tool. + + :ivar name: The built-in tool to configure. Required. Known values are: "filesystem_read", + "filesystem_write", "shell", "web", and "subagents". + :vartype name: str or ~azure.ai.projects.models.MicrosoftCopilotBuiltInTool + :ivar enabled: Whether the built-in tool is enabled. If omitted, the toolset default applies. + :vartype enabled: bool + """ + + name: Union[str, "_models.MicrosoftCopilotBuiltInTool"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The built-in tool to configure. Required. Known values are: \"filesystem_read\", + \"filesystem_write\", \"shell\", \"web\", and \"subagents\".""" + enabled: Optional[bool] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Whether the built-in tool is enabled. If omitted, the toolset default applies.""" + + @overload + def __init__( + self, + *, + name: Union[str, "_models.MicrosoftCopilotBuiltInTool"], + enabled: Optional[bool] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class MicrosoftCopilotToolsetDefaultConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """The default enablement setting for Microsoft Copilot built-in tools. + + :ivar enabled: Whether built-in tools are enabled by default. Defaults to true. + :vartype enabled: bool + """ + + enabled: Optional[bool] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Whether built-in tools are enabled by default. Defaults to true.""" + + @overload + def __init__( + self, + *, + enabled: Optional[bool] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class MicrosoftCopilotToolsetPreview( + Tool, discriminator="microsoft_copilot_toolset_preview" +): # pylint: disable=docstring-keyword-should-match-keyword-only + """Configuration overrides for Microsoft Copilot built-in tools. + + :ivar type: The type of the toolset. Always ``microsoft_copilot_toolset_preview``. Required. + MICROSOFT_COPILOT_TOOLSET_PREVIEW. + :vartype type: str or ~azure.ai.projects.models.MICROSOFT_COPILOT_TOOLSET_PREVIEW + :ivar default_config: The default configuration for built-in tools. If omitted, built-in tools + are enabled by default. + :vartype default_config: ~azure.ai.projects.models.MicrosoftCopilotToolsetDefaultConfig + :ivar configs: Per-tool configuration overrides. Duplicate built-in tool names are not allowed. + :vartype configs: list[~azure.ai.projects.models.MicrosoftCopilotToolsetConfig] + """ + + type: Literal[ToolType.MICROSOFT_COPILOT_TOOLSET_PREVIEW] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore + """The type of the toolset. Always ``microsoft_copilot_toolset_preview``. Required. + MICROSOFT_COPILOT_TOOLSET_PREVIEW.""" + default_config: Optional["_models.MicrosoftCopilotToolsetDefaultConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The default configuration for built-in tools. If omitted, built-in tools are enabled by + default.""" + configs: Optional[list["_models.MicrosoftCopilotToolsetConfig"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Per-tool configuration overrides. Duplicate built-in tool names are not allowed.""" + + @overload + def __init__( + self, + *, + default_config: Optional["_models.MicrosoftCopilotToolsetDefaultConfig"] = None, + configs: Optional[list["_models.MicrosoftCopilotToolsetConfig"]] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.type = ToolType.MICROSOFT_COPILOT_TOOLSET_PREVIEW # type: ignore + + class MicrosoftFabricPreviewTool( Tool, discriminator="fabric_dataagent_preview" ): # pylint: disable=docstring-keyword-should-match-keyword-only @@ -16658,7 +16829,7 @@ class ProtocolVersionRecord(_Model): # pylint: disable=docstring-keyword-should """A record mapping for a single protocol and its version. :ivar protocol: The protocol type. Required. Known values are: "activity", "responses", "a2a", - "mcp", "invocations", "voice", and "invocations_ws". + "mcp", "invocations", "voice", "invocations_ws", and "voice_conversation_engine". :vartype protocol: str or ~azure.ai.projects.models.AgentEndpointProtocol :ivar version: The version string for the protocol, e.g. 'v0.1.1'. Required. :vartype version: str @@ -16668,7 +16839,7 @@ class ProtocolVersionRecord(_Model): # pylint: disable=docstring-keyword-should visibility=["read", "create", "update", "delete", "query"] ) """The protocol type. Required. Known values are: \"activity\", \"responses\", \"a2a\", \"mcp\", - \"invocations\", \"voice\", and \"invocations_ws\".""" + \"invocations\", \"voice\", \"invocations_ws\", and \"voice_conversation_engine\".""" version: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The version string for the protocol, e.g. 'v0.1.1'. Required.""" @@ -22601,16 +22772,16 @@ class SessionConfiguration(_Model): # pylint: disable=docstring-keyword-should- """Session defaults applied to sessions created for a hosted agent version. :ivar idle_timeout_seconds: The idle duration, in seconds, before a session's sandbox is - suspended. Optional — when unset, the server default of 900 seconds is used. Must be between - 120 and 3600 seconds (inclusive). + suspended. When omitted, the server defaults to 900 seconds. Must be between 120 and 14400 + seconds (4 hours). :vartype idle_timeout_seconds: ~datetime.timedelta """ idle_timeout_seconds: Optional[datetime.timedelta] = rest_field( visibility=["read", "create", "update", "delete", "query"], format="duration-seconds-int" ) - """The idle duration, in seconds, before a session's sandbox is suspended. Optional — when unset, - the server default of 900 seconds is used. Must be between 120 and 3600 seconds (inclusive).""" + """The idle duration, in seconds, before a session's sandbox is suspended. When omitted, the + server defaults to 900 seconds. Must be between 120 and 14400 seconds (4 hours).""" @overload def __init__( @@ -22963,16 +23134,22 @@ class SimulationSeedDataGenerationJobConfiguration( :ivar type: The data generation job type, which is SimulationSeed for this model. Required. Simulation seed for evaluation scenarios. :vartype type: str or ~azure.ai.projects.models.SIMULATION_SEED + :ivar max_samples: Maximum number of samples to generate, up to service-defined limits. + Required. + :vartype max_samples: int """ type: Literal[DataGenerationJobType.SIMULATION_SEED] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore """The data generation job type, which is SimulationSeed for this model. Required. Simulation seed for evaluation scenarios.""" + max_samples: int = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Maximum number of samples to generate, up to service-defined limits. Required.""" @overload def __init__( self, *, + max_samples: int, train_split: Optional[float] = None, model_options: Optional["_models.DataGenerationModelOptions"] = None, ) -> None: ... @@ -23799,12 +23976,12 @@ class TelephonyBinding(_Model): # pylint: disable=docstring-keyword-should-matc """A telephony binding owned by a voice agent. You probably want to use the sub-classes and not this class directly. Known sub-classes are: - TeamsPhoneExtensionTelephonyBinding, TwilioTelephonyBinding + TeamsPhoneExtensibilityTelephonyBinding, TwilioTelephonyBinding :ivar id: The service-generated binding identifier. Required. :vartype id: str - :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extension" and - "twilio". + :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extensibility" + and "twilio". :vartype provider: str or ~azure.ai.projects.models.TelephonyProvider :ivar connection_name: The Foundry connection name for the telephony provider. Required. :vartype connection_name: str @@ -23821,7 +23998,8 @@ class TelephonyBinding(_Model): # pylint: disable=docstring-keyword-should-matc id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The service-generated binding identifier. Required.""" provider: str = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) - """The telephony provider. Required. Known values are: \"teams_phone_extension\" and \"twilio\".""" + """The telephony provider. Required. Known values are: \"teams_phone_extensibility\" and + \"twilio\".""" connection_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The Foundry connection name for the telephony provider. Required.""" label: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -23856,10 +24034,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class TeamsPhoneExtensionTelephonyBinding( - TelephonyBinding, discriminator="teams_phone_extension" +class TeamsPhoneExtensibilityTelephonyBinding( + TelephonyBinding, discriminator="teams_phone_extensibility" ): # pylint: disable=docstring-keyword-should-match-keyword-only - """A Microsoft Teams Phone Extension binding owned by a voice agent. + """A Microsoft Teams Phone extensibility binding owned by a voice agent. :ivar id: The service-generated binding identifier. Required. :vartype id: str @@ -23872,9 +24050,9 @@ class TeamsPhoneExtensionTelephonyBinding( :ivar incoming_call_url: The service-generated webhook URL to configure with the telephony provider. Required. :vartype incoming_call_url: str - :ivar provider: The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone - Extension. - :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSION + :ivar provider: The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams + Phone extensibility. + :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSIBILITY :ivar phone_number: The optional display phone number for the Teams resource account. :vartype phone_number: str :ivar resource_account_object_id: The Microsoft Teams resource-account object identifier as a @@ -23882,8 +24060,9 @@ class TeamsPhoneExtensionTelephonyBinding( :vartype resource_account_object_id: str """ - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore - """The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone Extension.""" + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore + """The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams Phone + extensibility.""" phone_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The optional display phone number for the Teams resource account.""" resource_account_object_id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -23911,19 +24090,19 @@ def __init__(self, mapping: Mapping[str, Any]) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSION # type: ignore + self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY # type: ignore class TelephonyBindingListItem(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """A telephony binding returned in a list, including its entity tag. You probably want to use the sub-classes and not this class directly. Known sub-classes are: - TeamsPhoneExtensionTelephonyBindingListItem, TwilioTelephonyBindingListItem + TeamsPhoneExtensibilityTelephonyBindingListItem, TwilioTelephonyBindingListItem :ivar id: The service-generated binding identifier. Required. :vartype id: str - :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extension" and - "twilio". + :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extensibility" + and "twilio". :vartype provider: str or ~azure.ai.projects.models.TelephonyProvider :ivar connection_name: The Foundry connection name for the telephony provider. Required. :vartype connection_name: str @@ -23943,7 +24122,8 @@ class TelephonyBindingListItem(_Model): # pylint: disable=docstring-keyword-sho id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The service-generated binding identifier. Required.""" provider: str = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) - """The telephony provider. Required. Known values are: \"teams_phone_extension\" and \"twilio\".""" + """The telephony provider. Required. Known values are: \"teams_phone_extensibility\" and + \"twilio\".""" connection_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The Foundry connection name for the telephony provider. Required.""" label: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -23981,10 +24161,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class TeamsPhoneExtensionTelephonyBindingListItem( - TelephonyBindingListItem, discriminator="teams_phone_extension" +class TeamsPhoneExtensibilityTelephonyBindingListItem( + TelephonyBindingListItem, discriminator="teams_phone_extensibility" ): # pylint: disable=name-too-long,docstring-keyword-should-match-keyword-only - """A Microsoft Teams Phone Extension binding returned in a list, including its entity tag. + """A Microsoft Teams Phone extensibility binding returned in a list, including its entity tag. :ivar id: The service-generated binding identifier. Required. :vartype id: str @@ -24000,9 +24180,9 @@ class TeamsPhoneExtensionTelephonyBindingListItem( :ivar etag: The entity tag to send in the ``If-Match`` header when updating or deleting this binding. Required. :vartype etag: str - :ivar provider: The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone - Extension. - :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSION + :ivar provider: The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams + Phone extensibility. + :vartype provider: str or ~azure.ai.projects.models.TEAMS_PHONE_EXTENSIBILITY :ivar phone_number: The optional display phone number for the Teams resource account. :vartype phone_number: str :ivar resource_account_object_id: The Microsoft Teams resource-account object identifier as a @@ -24010,8 +24190,9 @@ class TeamsPhoneExtensionTelephonyBindingListItem( :vartype resource_account_object_id: str """ - provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSION] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore - """The Microsoft Teams Phone Extension provider. Required. Microsoft Teams Phone Extension.""" + provider: Literal[TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY] = rest_discriminator(name="provider", visibility=["read", "create", "update", "delete", "query"]) # type: ignore + """The Microsoft Teams Phone extensibility provider. Required. Microsoft Teams Phone + extensibility.""" phone_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The optional display phone number for the Teams resource account.""" resource_account_object_id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -24039,7 +24220,7 @@ def __init__(self, mapping: Mapping[str, Any]) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSION # type: ignore + self.provider = TelephonyProvider.TEAMS_PHONE_EXTENSIBILITY # type: ignore class TeamsTelephonyTransferDestination( @@ -24115,13 +24296,13 @@ class TelephonyCallJob(_Model): # pylint: disable=docstring-keyword-should-matc :ivar destination: The phone destination to call. Required. :vartype destination: ~azure.ai.projects.models.TelephonyOutboundDestination :ivar connection_name: The Foundry connection name in the current project used to originate the - call. Its category selects Twilio or Azure Communication Services / Teams Phone Extension. No - inbound telephony binding is required. Required. + call. Its category selects Twilio or Azure Communication Services / Teams Phone extensibility. + No inbound telephony binding is required. Required. :vartype connection_name: str :ivar source: The caller identity used to originate the call. For a Twilio connection, provide - an authorized E.164 phone number. For an Azure Communication Services / Teams Phone Extension - connection, provide the Teams Resource Account object ID. The identity type is inferred from - the connection category; originating does not change inbound routing. Required. + an authorized E.164 phone number. For an Azure Communication Services / Teams Phone + extensibility connection, provide the Teams Resource Account object ID. The identity type is + inferred from the connection category; originating does not change inbound routing. Required. :vartype source: str :ivar purpose: An optional customer-declared purpose for placing the call. :vartype purpose: str @@ -24178,11 +24359,11 @@ class TelephonyCallJob(_Model): # pylint: disable=docstring-keyword-should-matc """The phone destination to call. Required.""" connection_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The Foundry connection name in the current project used to originate the call. Its category - selects Twilio or Azure Communication Services / Teams Phone Extension. No inbound telephony - binding is required. Required.""" + selects Twilio or Azure Communication Services / Teams Phone extensibility. No inbound + telephony binding is required. Required.""" source: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The caller identity used to originate the call. For a Twilio connection, provide an authorized - E.164 phone number. For an Azure Communication Services / Teams Phone Extension connection, + E.164 phone number. For an Azure Communication Services / Teams Phone extensibility connection, provide the Teams Resource Account object ID. The identity type is inferred from the connection category; originating does not change inbound routing. Required.""" purpose: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -24378,7 +24559,7 @@ class TelephonyCallLifecycleEvent(_Model): # pylint: disable=docstring-keyword- "telephony.call.transfer", "telephony.call.hangup", and "telephony.call.disconnect". :vartype name: str or ~azure.ai.projects.models.TelephonyCallLifecycleEventName :ivar source: The component that supplied the observation. Required. Known values are: - "gateway", "teams_phone_extension", "twilio", and "voice_agent". + "gateway", "teams_phone_extensibility", "twilio", and "voice_agent". :vartype source: str or ~azure.ai.projects.models.TelephonyCallLifecycleEventSource :ivar outcome: The outcome of the observed lifecycle operation. Required. Known values are: "observed", "started", "succeeded", "failed", "rejected", and "cancelled". @@ -24429,7 +24610,7 @@ class TelephonyCallLifecycleEvent(_Model): # pylint: disable=docstring-keyword- visibility=["read", "create", "update", "delete", "query"] ) """The component that supplied the observation. Required. Known values are: \"gateway\", - \"teams_phone_extension\", \"twilio\", and \"voice_agent\".""" + \"teams_phone_extensibility\", \"twilio\", and \"voice_agent\".""" outcome: Union[str, "_models.TelephonyCallLifecycleEventOutcome"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -24505,14 +24686,14 @@ class TelephonyCallRecord(_Model): # pylint: disable=docstring-keyword-should-m :ivar id: The service-generated call identifier. Required. :vartype id: str - :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extension" and - "twilio". + :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extensibility" + and "twilio". :vartype provider: str or ~azure.ai.projects.models.TelephonyProvider :ivar provider_call_id: The provider-assigned call identifier, when available. :vartype provider_call_id: str :ivar caller_number: The caller's phone number, when supplied by the provider. :vartype caller_number: str - :ivar provider_number: The Teams Phone Extension or Twilio number that received the call. + :ivar provider_number: The Teams Phone extensibility or Twilio number that received the call. :vartype provider_number: str :ivar status: The lifecycle status of the call. Required. Known values are: "in_progress", "success", and "failed". @@ -24571,13 +24752,14 @@ class TelephonyCallRecord(_Model): # pylint: disable=docstring-keyword-should-m provider: Union[str, "_models.TelephonyProvider"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) - """The telephony provider. Required. Known values are: \"teams_phone_extension\" and \"twilio\".""" + """The telephony provider. Required. Known values are: \"teams_phone_extensibility\" and + \"twilio\".""" provider_call_id: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The provider-assigned call identifier, when available.""" caller_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The caller's phone number, when supplied by the provider.""" provider_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The Teams Phone Extension or Twilio number that received the call.""" + """The Teams Phone extensibility or Twilio number that received the call.""" status: Union[str, "_models.TelephonyCallStatus"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -24691,14 +24873,14 @@ class TelephonyCallSummary(_Model): # pylint: disable=docstring-keyword-should- :ivar id: The service-generated call identifier. Required. :vartype id: str - :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extension" and - "twilio". + :ivar provider: The telephony provider. Required. Known values are: "teams_phone_extensibility" + and "twilio". :vartype provider: str or ~azure.ai.projects.models.TelephonyProvider :ivar provider_call_id: The provider-assigned call identifier, when available. :vartype provider_call_id: str :ivar caller_number: The caller's phone number, when supplied by the provider. :vartype caller_number: str - :ivar provider_number: The Teams Phone Extension or Twilio number that received the call. + :ivar provider_number: The Teams Phone extensibility or Twilio number that received the call. :vartype provider_number: str :ivar status: The lifecycle status of the call. Required. Known values are: "in_progress", "success", and "failed". @@ -24748,13 +24930,14 @@ class TelephonyCallSummary(_Model): # pylint: disable=docstring-keyword-should- provider: Union[str, "_models.TelephonyProvider"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) - """The telephony provider. Required. Known values are: \"teams_phone_extension\" and \"twilio\".""" + """The telephony provider. Required. Known values are: \"teams_phone_extensibility\" and + \"twilio\".""" provider_call_id: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The provider-assigned call identifier, when available.""" caller_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The caller's phone number, when supplied by the provider.""" provider_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The Teams Phone Extension or Twilio number that received the call.""" + """The Teams Phone extensibility or Twilio number that received the call.""" status: Union[str, "_models.TelephonyCallStatus"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -27045,11 +27228,11 @@ class UpdateTelephonyBindingRequest(_Model): # pylint: disable=docstring-keywor clear it. :vartype label: str :ivar connection_name: The replacement Foundry connection name. This property is valid only for - a Teams Phone Extension binding; a Twilio binding's connection is immutable. + a Teams Phone extensibility binding; a Twilio binding's connection is immutable. :vartype connection_name: str - :ivar phone_number: The replacement Teams Phone Extension display phone number. Omit it to + :ivar phone_number: The replacement Teams Phone extensibility display phone number. Omit it to preserve the current value; use null to clear it. This property is valid only for a Teams Phone - Extension binding. + extensibility binding. :vartype phone_number: str """ @@ -27061,10 +27244,11 @@ class UpdateTelephonyBindingRequest(_Model): # pylint: disable=docstring-keywor """The replacement display label. Omit it to preserve the current value; use null to clear it.""" connection_name: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The replacement Foundry connection name. This property is valid only for a Teams Phone - Extension binding; a Twilio binding's connection is immutable.""" + extensibility binding; a Twilio binding's connection is immutable.""" phone_number: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The replacement Teams Phone Extension display phone number. Omit it to preserve the current - value; use null to clear it. This property is valid only for a Teams Phone Extension binding.""" + """The replacement Teams Phone extensibility display phone number. Omit it to preserve the current + value; use null to clear it. This property is valid only for a Teams Phone extensibility + binding.""" @overload def __init__( @@ -28613,6 +28797,11 @@ class VoiceAgentDefinition( detection, noise reduction, and transcription. These values are session defaults; a client may override supported fields when connecting. :vartype audio: ~azure.ai.projects.models.VoiceAgentAudioConfig + :ivar soundscape: Optional background ambience and event cues, stored with the agent version + and separate from the audio configuration. Currently rendered only for telephony calls, not + browser WebRTC or direct voice WebSocket sessions. Omit this property to disable all auxiliary + audio. Persisted conversation recordings do not include the soundscape mix. + :vartype soundscape: ~azure.ai.projects.models.VoiceAgentSoundscapeConfig :ivar output_modalities: The output modalities the agent produces. Defaults to ``["audio"]``. ``animation`` and ``avatar`` are available when an avatar is configured. :vartype output_modalities: list[str or ~azure.ai.projects.models.VoiceOutputModality] @@ -28692,6 +28881,13 @@ class VoiceAgentDefinition( """The audio configuration, including input and output formats, voice, turn detection, noise reduction, and transcription. These values are session defaults; a client may override supported fields when connecting.""" + soundscape: Optional["_models.VoiceAgentSoundscapeConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Optional background ambience and event cues, stored with the agent version and separate from + the audio configuration. Currently rendered only for telephony calls, not browser WebRTC or + direct voice WebSocket sessions. Omit this property to disable all auxiliary audio. Persisted + conversation recordings do not include the soundscape mix.""" output_modalities: Optional[list[Union[str, "_models.VoiceOutputModality"]]] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -28761,6 +28957,7 @@ def __init__( instructions: Optional[str] = None, greeting: Optional["_models.VoiceAgentGreetingConfig"] = None, audio: Optional["_models.VoiceAgentAudioConfig"] = None, + soundscape: Optional["_models.VoiceAgentSoundscapeConfig"] = None, output_modalities: Optional[list[Union[str, "_models.VoiceOutputModality"]]] = None, max_output_tokens: Optional["_unions.VoiceAgentMaxOutputTokens"] = None, include: Optional[list[Union[str, "_models.VoiceAgentSessionIncludeOption"]]] = None, @@ -29082,12 +29279,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class VoiceAgentInputTranscription(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """Asynchronous input-audio transcription configuration. Extends the OpenAI Realtime transcription - options with the Azure and MAI transcription models, custom speech models, and phrase hints. + """Settings for transcribing input audio, including the transcription model, Custom Speech + deployments, and phrase hints. When ``model`` is ``azure-speech-streaming``, transcription is + supported for cascaded text-model conversations only (not realtime speech-to-speech models), + requires a nonempty ``language`` such as ``en-US`` (comma-separated locales are supported), and + supports ``custom_speech``, ``phrase_list``, and ``profanity_filter``. :ivar language: The language of the input audio. Supplying the input language in `ISO-639-1 `_ (e.g. ``en``) format will improve - accuracy and latency. + accuracy and latency. Optional for other transcription models; required and must be nonempty + when ``model`` is ``azure-speech-streaming`` (such as ``en-US``; comma-separated locales are + supported). :vartype language: str :ivar languages: Possible languages of the input audio, in `ISO-639-1 `_ format. Supported by @@ -29108,20 +29310,34 @@ class VoiceAgentInputTranscription(_Model): # pylint: disable=docstring-keyword Literal["minimal"], Literal["low"], Literal["medium"], Literal["high"], Literal["xhigh"] :vartype delay: str or str or str or str or str :ivar model: The transcription model identifier. Configure customer custom speech deployments - in ``custom_speech``. Required. Known values are: "whisper-1", "gpt-realtime-whisper", + in ``custom_speech``. ``azure-speech-streaming`` is for cascaded text-model conversations only + (not realtime speech-to-speech models), requires a nonempty ``language`` such as ``en-US`` + (comma-separated locales are supported), and supports ``custom_speech``, ``phrase_list``, and + ``profanity_filter``. Required. Known values are: "whisper-1", "gpt-realtime-whisper", "gpt-4o-transcribe", "gpt-4o-mini-transcribe", "gpt-4o-transcribe-diarize", "gpt-transcribe", - "gpt-live-transcribe", "mai-transcribe", and "azure-speech". + "gpt-live-transcribe", "mai-transcribe", "azure-speech", and "azure-speech-streaming". :vartype model: str or ~azure.ai.projects.models.VoiceAgentInputTranscriptionModel :ivar custom_speech: Optional customer custom speech deployment configuration, keyed by locale. :vartype custom_speech: dict[str, str] :ivar phrase_list: Optional phrase hints that bias recognition toward domain terms. :vartype phrase_list: list[str] + :ivar profanity_filter: Controls how profanity is handled in input transcripts. Supported only + with managed Azure Speech (``azure-speech`` and ``azure-speech-streaming``), including Custom + Speech. Set this property before the session starts. If omitted, the transcription provider's + default applies. You cannot change the mode during a session. Filtering affects transcript + events and the text sent to cascaded models. It does not change input audio, voice activity + detection, or assistant speech; audio-native models still receive the original audio. Mandatory + service masking policies apply even when you select ``unfiltered``. Known values are: "masked", + "unfiltered", and "removed". + :vartype profanity_filter: str or ~azure.ai.projects.models.VoiceAgentProfanityFilterMode """ language: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The language of the input audio. Supplying the input language in `ISO-639-1 `_ (e.g. ``en``) format will improve - accuracy and latency.""" + accuracy and latency. Optional for other transcription models; required and must be nonempty + when ``model`` is ``azure-speech-streaming`` (such as ``en-US``; comma-separated locales are + supported).""" languages: Optional[list[str]] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """Possible languages of the input audio, in `ISO-639-1 `_ format. Supported by @@ -29146,13 +29362,28 @@ class VoiceAgentInputTranscription(_Model): # pylint: disable=docstring-keyword visibility=["read", "create", "update", "delete", "query"] ) """The transcription model identifier. Configure customer custom speech deployments in - ``custom_speech``. Required. Known values are: \"whisper-1\", \"gpt-realtime-whisper\", + ``custom_speech``. ``azure-speech-streaming`` is for cascaded text-model conversations only + (not realtime speech-to-speech models), requires a nonempty ``language`` such as ``en-US`` + (comma-separated locales are supported), and supports ``custom_speech``, ``phrase_list``, and + ``profanity_filter``. Required. Known values are: \"whisper-1\", \"gpt-realtime-whisper\", \"gpt-4o-transcribe\", \"gpt-4o-mini-transcribe\", \"gpt-4o-transcribe-diarize\", - \"gpt-transcribe\", \"gpt-live-transcribe\", \"mai-transcribe\", and \"azure-speech\".""" + \"gpt-transcribe\", \"gpt-live-transcribe\", \"mai-transcribe\", \"azure-speech\", and + \"azure-speech-streaming\".""" custom_speech: Optional[dict[str, str]] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """Optional customer custom speech deployment configuration, keyed by locale.""" phrase_list: Optional[list[str]] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """Optional phrase hints that bias recognition toward domain terms.""" + profanity_filter: Optional[Union[str, "_models.VoiceAgentProfanityFilterMode"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Controls how profanity is handled in input transcripts. Supported only with managed Azure + Speech (``azure-speech`` and ``azure-speech-streaming``), including Custom Speech. Set this + property before the session starts. If omitted, the transcription provider's default applies. + You cannot change the mode during a session. Filtering affects transcript events and the text + sent to cascaded models. It does not change input audio, voice activity detection, or assistant + speech; audio-native models still receive the original audio. Mandatory service masking + policies apply even when you select ``unfiltered``. Known values are: \"masked\", + \"unfiltered\", and \"removed\".""" @overload def __init__( @@ -29166,6 +29397,7 @@ def __init__( delay: Optional[Literal["minimal", "low", "medium", "high", "xhigh"]] = None, custom_speech: Optional[dict[str, str]] = None, phrase_list: Optional[list[str]] = None, + profanity_filter: Optional[Union[str, "_models.VoiceAgentProfanityFilterMode"]] = None, ) -> None: ... @overload @@ -29326,10 +29558,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class VoiceAgentMcpTool( VoiceAgentTool, discriminator="mcp" ): # pylint: disable=docstring-keyword-should-match-keyword-only - """An MCP tool available to a voice agent. + """An MCP tool available to a voice agent. Server endpoints use ``server_url``; connector and + tunnel identifiers are not supported. :ivar server_label: A label for this MCP server, used to identify it in tool calls. Required. :vartype server_label: str + :ivar server_url: The URL for the MCP server. One of ``server_url``, ``connector_id``, or + ``tunnel_id`` must be provided. + :vartype server_url: str :ivar authorization: An OAuth access token that can be used with a remote MCP server, either with a custom MCP server URL or a service connector. Your application must handle the OAuth authorization flow and provide the token here. @@ -29356,8 +29592,6 @@ class VoiceAgentMcpTool( :vartype tool_configs: dict[str, ~azure.ai.projects.models.ToolConfig] :ivar type: Required. Default value is "mcp". :vartype type: str - :ivar server_url: The URL for the MCP server. - :vartype server_url: str :ivar response_scheduling: When the MCP invocation creates a follow-up response. Defaults to ``when_idle``. Known values are: "silent", "when_idle", "interrupt", and "skip_if_busy". :vartype response_scheduling: str or ~azure.ai.projects.models.VoiceAgentToolResponseScheduling @@ -29365,6 +29599,9 @@ class VoiceAgentMcpTool( server_label: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) """A label for this MCP server, used to identify it in tool calls. Required.""" + server_url: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """The URL for the MCP server. One of ``server_url``, ``connector_id``, or ``tunnel_id`` must be + provided.""" authorization: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """An OAuth access token that can be used with a remote MCP server, either with a custom MCP server URL or a service connector. Your application must handle the OAuth authorization flow @@ -29394,8 +29631,6 @@ class VoiceAgentMcpTool( """Deprecated. This property is deprecated and will be removed in a future version.""" type: Literal["mcp"] = rest_discriminator(name="type", visibility=["read", "create", "update", "delete", "query"]) # type: ignore """Required. Default value is \"mcp\".""" - server_url: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The URL for the MCP server.""" response_scheduling: Optional[Union[str, "_models.VoiceAgentToolResponseScheduling"]] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -29407,6 +29642,7 @@ def __init__( self, *, server_label: str, + server_url: Optional[str] = None, authorization: Optional[str] = None, server_description: Optional[str] = None, headers: Optional[dict[str, str]] = None, @@ -29416,7 +29652,6 @@ def __init__( defer_loading: Optional[bool] = None, project_connection_id: Optional[str] = None, tool_configs: Optional[dict[str, "_models.ToolConfig"]] = None, - server_url: Optional[str] = None, response_scheduling: Optional[Union[str, "_models.VoiceAgentToolResponseScheduling"]] = None, ) -> None: ... @@ -29433,18 +29668,34 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: class VoiceAgentNoiseReduction(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """Input audio noise reduction configuration. - - :ivar type: The noise reduction mode. Required. Known values are: "near_field", "far_field", - and "azure_deep_noise_suppression". + """Input audio noise reduction configuration. ``azure_deep_noise_suppression_headset`` and + ``azure_personal_noise_suppression`` are modes for local voice activity detection that enhance + detection audio without changing the audio used for recognition. + + :ivar type: The noise reduction mode. ``azure_deep_noise_suppression_headset`` provides + headset-optimized noise suppression for local voice activity detection, optimized for + low-sample-rate audio including 8 kHz telephony audio without changing recognition audio (not + restricted to headset devices). ``azure_personal_noise_suppression`` starts with headset + suppression for local voice activity detection and personalizes detection after automatic + speaker enrollment while keeping recognition audio unchanged and keeping headset suppression + active when personalization is unavailable. Required. Known values are: "near_field", + "far_field", "azure_deep_noise_suppression", "azure_deep_noise_suppression_headset", and + "azure_personal_noise_suppression". :vartype type: str or ~azure.ai.projects.models.VoiceAgentNoiseReductionType """ type: Union[str, "_models.VoiceAgentNoiseReductionType"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) - """The noise reduction mode. Required. Known values are: \"near_field\", \"far_field\", and - \"azure_deep_noise_suppression\".""" + """The noise reduction mode. ``azure_deep_noise_suppression_headset`` provides headset-optimized + noise suppression for local voice activity detection, optimized for low-sample-rate audio + including 8 kHz telephony audio without changing recognition audio (not restricted to headset + devices). ``azure_personal_noise_suppression`` starts with headset suppression for local voice + activity detection and personalizes detection after automatic speaker enrollment while keeping + recognition audio unchanged and keeping headset suppression active when personalization is + unavailable. Required. Known values are: \"near_field\", \"far_field\", + \"azure_deep_noise_suppression\", \"azure_deep_noise_suppression_headset\", and + \"azure_personal_noise_suppression\".""" @overload def __init__( @@ -29464,8 +29715,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class VoiceAgentRealtimeResponseBase(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """Properties shared by realtime responses returned by the voice-agent service. +class VoiceAgentRealtimeResponse(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """A live realtime response returned by the voice-agent service in both ``response.created`` and + ``response.done`` events. :ivar id: The unique ID of the response, will look like ``resp_1234``. :vartype id: str @@ -29478,6 +29730,8 @@ class VoiceAgentRealtimeResponseBase(_Model): # pylint: disable=docstring-keywo :vartype status: str or str or str or str or str :ivar status_details: Additional details about the status. :vartype status_details: ~azure.ai.projects.models.RealtimeResponseStatusDetails + :ivar output: The list of output items generated by the response. + :vartype output: list[~azure.ai.projects.models.RealtimeConversationItem] :ivar metadata: :vartype metadata: ~azure.ai.projects.models.Metadata :ivar usage: Usage statistics for the Response, this will correspond to billing. A Realtime API @@ -29499,6 +29753,9 @@ class VoiceAgentRealtimeResponseBase(_Model): # pylint: disable=docstring-keywo inclusive of tool calls, that was used in this response. Is either a int type or a Literal["inf"] type. :vartype max_output_tokens: int or str + :ivar audio: The audio configuration used by the live response, including flat voice provider, + locale, and format fields under ``output``. + :vartype audio: ~azure.ai.projects.models.VoiceResponseAudio """ id: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) @@ -29517,6 +29774,10 @@ class VoiceAgentRealtimeResponseBase(_Model): # pylint: disable=docstring-keywo visibility=["read", "create", "update", "delete", "query"] ) """Additional details about the status.""" + output: Optional[list["_models.RealtimeConversationItem"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The list of output items generated by the response.""" metadata: Optional["_models.Metadata"] = rest_field(visibility=["read", "create", "update", "delete", "query"]) usage: Optional["_models.RealtimeResponseUsage"] = rest_field( visibility=["read", "create", "update", "delete", "query"] @@ -29542,87 +29803,11 @@ class VoiceAgentRealtimeResponseBase(_Model): # pylint: disable=docstring-keywo ) """Maximum number of output tokens for a single assistant response, inclusive of tool calls, that was used in this response. Is either a int type or a Literal[\"inf\"] type.""" - - @overload - def __init__( - self, - *, - id: Optional[str] = None, # pylint: disable=redefined-builtin - object: Optional[Literal["realtime.response"]] = None, - status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] = None, - status_details: Optional["_models.RealtimeResponseStatusDetails"] = None, - metadata: Optional["_models.Metadata"] = None, - usage: Optional["_models.RealtimeResponseUsage"] = None, - conversation_id: Optional[str] = None, - output_modalities: Optional[list[Literal["text", "audio"]]] = None, - max_output_tokens: Optional[Union[int, Literal["inf"]]] = None, - ) -> None: ... - - @overload - def __init__(self, mapping: Mapping[str, Any]) -> None: - """ - :param mapping: raw JSON to initialize the model. - :type mapping: Mapping[str, Any] - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - -class VoiceAgentRealtimeResponse( - VoiceAgentRealtimeResponseBase -): # pylint: disable=docstring-keyword-should-match-keyword-only - """A live realtime response returned by the voice-agent service in both ``response.created`` and - ``response.done`` events. - - :ivar id: The unique ID of the response, will look like ``resp_1234``. - :vartype id: str - :ivar object: The object type, must be ``realtime.response``. Default value is - "realtime.response". - :vartype object: str - :ivar status: The final status of the response (``completed``, ``cancelled``, ``failed``, or - ``incomplete``, ``in_progress``). Is one of the following types: Literal["completed"], - Literal["cancelled"], Literal["failed"], Literal["incomplete"], Literal["in_progress"] - :vartype status: str or str or str or str or str - :ivar status_details: Additional details about the status. - :vartype status_details: ~azure.ai.projects.models.RealtimeResponseStatusDetails - :ivar metadata: - :vartype metadata: ~azure.ai.projects.models.Metadata - :ivar usage: Usage statistics for the Response, this will correspond to billing. A Realtime API - session will maintain a conversation context and append new Items to the Conversation, thus - output from previous turns (text and audio tokens) will become the input for later turns. - :vartype usage: ~azure.ai.projects.models.RealtimeResponseUsage - :ivar conversation_id: Which conversation the response is added to, determined by the - ``conversation`` field in the ``response.create`` event. If ``auto``, the response will be - added to the default conversation and the value of ``conversation_id`` will be an id like - ``conv_1234``. If ``none``, the response will not be added to any conversation and the value of - ``conversation_id`` will be ``null``. If responses are being triggered automatically by VAD the - response will be added to the default conversation. - :vartype conversation_id: str - :ivar output_modalities: The set of modalities the model used to respond, currently the only - possible values are ``[\\"audio\\"]``, ``[\\"text\\"]``. Audio output always include a text - transcript. Setting the output to mode ``text`` will disable audio output from the model. - :vartype output_modalities: list[str or str] - :ivar max_output_tokens: Maximum number of output tokens for a single assistant response, - inclusive of tool calls, that was used in this response. Is either a int type or a - Literal["inf"] type. - :vartype max_output_tokens: int or str - :ivar audio: The audio configuration used by the live response, including flat voice provider, - locale, and format fields under ``output``. - :vartype audio: ~azure.ai.projects.models.VoiceResponseAudio - :ivar output: The items produced by the live response. - :vartype output: list[~azure.ai.projects.models.RealtimeConversationItem] - """ - audio: Optional["_models.VoiceResponseAudio"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) """The audio configuration used by the live response, including flat voice provider, locale, and format fields under ``output``.""" - output: Optional[list["_models.RealtimeConversationItem"]] = rest_field( - visibility=["read", "create", "update", "delete", "query"] - ) - """The items produced by the live response.""" @overload def __init__( @@ -29632,13 +29817,13 @@ def __init__( object: Optional[Literal["realtime.response"]] = None, status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] = None, status_details: Optional["_models.RealtimeResponseStatusDetails"] = None, + output: Optional[list["_models.RealtimeConversationItem"]] = None, metadata: Optional["_models.Metadata"] = None, usage: Optional["_models.RealtimeResponseUsage"] = None, conversation_id: Optional[str] = None, output_modalities: Optional[list[Literal["text", "audio"]]] = None, max_output_tokens: Optional[Union[int, Literal["inf"]]] = None, audio: Optional["_models.VoiceResponseAudio"] = None, - output: Optional[list["_models.RealtimeConversationItem"]] = None, ) -> None: ... @overload @@ -29690,12 +29875,15 @@ class VoiceAgentResponseCreateParams(_Model): # pylint: disable=docstring-keywo :vartype conversation: str or str or str :ivar metadata: :vartype metadata: ~azure.ai.projects.models.Metadata + :ivar input: Input items to include in the prompt for the model. Using this field creates a new + context for this Response instead of using the default conversation. An empty array ``[]`` will + clear the context for this Response. Note that this can include references to items that + previously appeared in the session using their id. + :vartype input: list[~azure.ai.projects.models.RealtimeConversationItem] :ivar output_modalities: Modalities that the response may return. :vartype output_modalities: list[str or ~azure.ai.projects.models.VoiceOutputModality] :ivar audio: Response-specific audio settings. :vartype audio: ~azure.ai.projects.models.PickPropertiesVoiceAgentAudioConfig - :ivar input: Conversation items used as inline response input. - :vartype input: list[~azure.ai.projects.models.RealtimeConversationItem] :ivar pre_generated_assistant_message: A pre-generated assistant message used to begin the response. :vartype pre_generated_assistant_message: ~azure.ai.projects.models.RealtimeConversationItem @@ -29744,6 +29932,13 @@ class VoiceAgentResponseCreateParams(_Model): # pylint: disable=docstring-keywo response which will not add items to default conversation. Is one of the following types: Literal[\"auto\"], Literal[\"none\"], str""" metadata: Optional["_models.Metadata"] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + input: Optional[list["_models.RealtimeConversationItem"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Input items to include in the prompt for the model. Using this field creates a new context for + this Response instead of using the default conversation. An empty array ``[]`` will clear the + context for this Response. Note that this can include references to items that previously + appeared in the session using their id.""" output_modalities: Optional[list[Union[str, "_models.VoiceOutputModality"]]] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -29752,10 +29947,6 @@ class VoiceAgentResponseCreateParams(_Model): # pylint: disable=docstring-keywo visibility=["read", "create", "update", "delete", "query"] ) """Response-specific audio settings.""" - input: Optional[list["_models.RealtimeConversationItem"]] = rest_field( - visibility=["read", "create", "update", "delete", "query"] - ) - """Conversation items used as inline response input.""" pre_generated_assistant_message: Optional["_models.RealtimeConversationItem"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -29779,9 +29970,9 @@ def __init__( max_output_tokens: Optional[Union[int, Literal["inf"]]] = None, conversation: Optional[Union[Literal["auto"], Literal["none"], str]] = None, metadata: Optional["_models.Metadata"] = None, + input: Optional[list["_models.RealtimeConversationItem"]] = None, output_modalities: Optional[list[Union[str, "_models.VoiceOutputModality"]]] = None, audio: Optional["_models.PickPropertiesVoiceAgentAudioConfig"] = None, - input: Optional[list["_models.RealtimeConversationItem"]] = None, pre_generated_assistant_message: Optional["_models.RealtimeConversationItem"] = None, interim_response: Optional["_models.VoiceAgentInterimResponseConfig"] = None, ) -> None: ... @@ -31184,6 +31375,194 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.type: Literal["realtime"] = "realtime" +class VoiceAgentSoundscapeConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """Agent-owned background ambience and event cues. Each omitted track is disabled. The service + rejects unknown fields; custom uploads, URLs, and inline audio are not supported. + + :ivar background: Continuous background ambience that continues during caller and agent speech. + :vartype background: ~azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig + :ivar working: A loop played while a non-call-control tool is pending and the agent is silent, + after the configured delay. + :vartype working: ~azure.ai.projects.models.VoiceAgentSoundscapeWorkingTrackConfig + :ivar opening: An interruptible cue played before the first agent speech. + :vartype opening: ~azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig + :ivar transfer: A one-shot cue played after queued agent speech drains and before a committed + call transfer. + :vartype transfer: ~azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig + :ivar closing: A one-shot cue played after queued agent speech drains and before a committed + session end. + :vartype closing: ~azure.ai.projects.models.VoiceAgentSoundscapeTrackConfig + """ + + background: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Continuous background ambience that continues during caller and agent speech.""" + working: Optional["_models.VoiceAgentSoundscapeWorkingTrackConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """A loop played while a non-call-control tool is pending and the agent is silent, after the + configured delay.""" + opening: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """An interruptible cue played before the first agent speech.""" + transfer: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """A one-shot cue played after queued agent speech drains and before a committed call transfer.""" + closing: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """A one-shot cue played after queued agent speech drains and before a committed session end.""" + + @overload + def __init__( + self, + *, + background: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = None, + working: Optional["_models.VoiceAgentSoundscapeWorkingTrackConfig"] = None, + opening: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = None, + transfer: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = None, + closing: Optional["_models.VoiceAgentSoundscapeTrackConfig"] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class VoiceAgentSoundscapeSourceConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """A packaged sound selected from the service-managed catalogue. The service rejects unknown + source fields and discriminator values. + + :ivar type: The audio source type. Always ``built_in``. Required. Default value is "built_in". + :vartype type: str + :ivar name: The name of the packaged sound. Only the listed built-in sounds are accepted. + Required. Known values are: "office", "call_center", "busy_street", "people_talking", + "street_and_metro_station", "ding1", and "ding2". + :vartype name: str or ~azure.ai.projects.models.VoiceAgentSoundscapeSound + """ + + type: Literal["built_in"] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """The audio source type. Always ``built_in``. Required. Default value is \"built_in\".""" + name: Union[str, "_models.VoiceAgentSoundscapeSound"] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The name of the packaged sound. Only the listed built-in sounds are accepted. Required. Known + values are: \"office\", \"call_center\", \"busy_street\", \"people_talking\", + \"street_and_metro_station\", \"ding1\", and \"ding2\".""" + + @overload + def __init__( + self, + *, + name: Union[str, "_models.VoiceAgentSoundscapeSound"], + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.type: Literal["built_in"] = "built_in" + + +class VoiceAgentSoundscapeTrackConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """A soundscape track with its own audio source and playback volume. + + :ivar source: The required audio source for this track. Required. + :vartype source: ~azure.ai.projects.models.VoiceAgentSoundscapeSourceConfig + :ivar gain: Linear playback gain from 0 to 1, independent of agent speech volume. If omitted, + the service defaults to 1. A value of 0 mutes this track. + :vartype gain: float + """ + + source: "_models.VoiceAgentSoundscapeSourceConfig" = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The required audio source for this track. Required.""" + gain: Optional[float] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Linear playback gain from 0 to 1, independent of agent speech volume. If omitted, the service + defaults to 1. A value of 0 mutes this track.""" + + @overload + def __init__( + self, + *, + source: "_models.VoiceAgentSoundscapeSourceConfig", + gain: Optional[float] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class VoiceAgentSoundscapeWorkingTrackConfig(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """A working-loop track with a delay measured from the first pending non-call-control tool. + + :ivar source: The required audio source for this track. Required. + :vartype source: ~azure.ai.projects.models.VoiceAgentSoundscapeSourceConfig + :ivar gain: Linear playback gain from 0 to 1, independent of agent speech volume. If omitted, + the service defaults to 1. A value of 0 mutes this track. + :vartype gain: float + :ivar delay_ms: The delay in milliseconds before working audio starts, from 0 to 10000. If + omitted, the service defaults to 1000 milliseconds. This delays only working audio, not tool + execution, and is valid only on the working track. + :vartype delay_ms: ~datetime.timedelta + """ + + source: "_models.VoiceAgentSoundscapeSourceConfig" = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The required audio source for this track. Required.""" + gain: Optional[float] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Linear playback gain from 0 to 1, independent of agent speech volume. If omitted, the service + defaults to 1. A value of 0 mutes this track.""" + delay_ms: Optional[datetime.timedelta] = rest_field( + visibility=["read", "create", "update", "delete", "query"], format="duration-milliseconds-int" + ) + """The delay in milliseconds before working audio starts, from 0 to 10000. If omitted, the service + defaults to 1000 milliseconds. This delays only working audio, not tool execution, and is valid + only on the working track.""" + + @overload + def __init__( + self, + *, + source: "_models.VoiceAgentSoundscapeSourceConfig", + gain: Optional[float] = None, + delay_ms: Optional[datetime.timedelta] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + class VoiceAgentStaticInterimResponseConfig( VoiceAgentInterimResponseConfig, discriminator="static_interim_response" ): # pylint: disable=docstring-keyword-should-match-keyword-only @@ -31885,8 +32264,12 @@ class VoiceHostedAgentConversationEngine( VoiceConversationEngine, discriminator="hosted_agent" ): # pylint: disable=docstring-keyword-should-match-keyword-only """A closed reference to the hosted text agent that owns conversation handling for a voice agent. - The hosted agent is resolved within the same project and must support the ``invocations_ws`` - protocol, Voice Live compatibility, and Bridge Protocol 1.0. + The hosted agent is resolved within the same project and must declare + ``voice_conversation_engine`` version ``1.0.0``. Voice Live connects through the dedicated + voice-conversation-engine endpoint using the existing Bridge Protocol 1.0 wire semantics; the + Bridge message version is independent of the container protocol version and the Hosted Agents + API version ``v1``. Declaring ``invocations_ws`` is optional and does not substitute for + ``voice_conversation_engine``. :ivar type: Selects a hosted Foundry agent as the conversation engine. Required. Default value is "hosted_agent". @@ -32029,9 +32412,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.right: Literal["agent"] = "agent" -class VoiceResponseBase(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only - """Properties shared by persisted voice responses. +class VoiceResponse(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """A persisted voice response representing one model inference turn within a conversation. In list + results the ``output`` projection may be omitted; retrieve the full response (``GET + .../responses/{response_id}``) or the paged response-items route (``GET + .../responses/{response_id}/items``) for its output items. ``created_at``/``completed_at`` are + Foundry durable ordering extensions. + :ivar id: The unique ID of the response, will look like ``resp_1234``. + :vartype id: str :ivar object: The object type, must be ``realtime.response``. Default value is "realtime.response". :vartype object: str @@ -32041,10 +32430,21 @@ class VoiceResponseBase(_Model): # pylint: disable=docstring-keyword-should-mat :vartype status: str or str or str or str or str :ivar status_details: Additional details about the status. :vartype status_details: ~azure.ai.projects.models.RealtimeResponseStatusDetails + :ivar output: The list of output items generated by the response. + :vartype output: list[~azure.ai.projects.models.RealtimeConversationItem] + :ivar metadata: + :vartype metadata: ~azure.ai.projects.models.Metadata :ivar usage: Usage statistics for the Response, this will correspond to billing. A Realtime API session will maintain a conversation context and append new Items to the Conversation, thus output from previous turns (text and audio tokens) will become the input for later turns. :vartype usage: ~azure.ai.projects.models.RealtimeResponseUsage + :ivar conversation_id: Which conversation the response is added to, determined by the + ``conversation`` field in the ``response.create`` event. If ``auto``, the response will be + added to the default conversation and the value of ``conversation_id`` will be an id like + ``conv_1234``. If ``none``, the response will not be added to any conversation and the value of + ``conversation_id`` will be ``null``. If responses are being triggered automatically by VAD the + response will be added to the default conversation. + :vartype conversation_id: str :ivar output_modalities: The set of modalities the model used to respond, currently the only possible values are ``[\\"audio\\"]``, ``[\\"text\\"]``. Audio output always include a text transcript. Setting the output to mode ``text`` will disable audio output from the model. @@ -32053,8 +32453,19 @@ class VoiceResponseBase(_Model): # pylint: disable=docstring-keyword-should-mat inclusive of tool calls, that was used in this response. Is either a int type or a Literal["inf"] type. :vartype max_output_tokens: int or str + :ivar audio: The audio configuration used for the response, including the voice and audio + format used for output. + :vartype audio: ~azure.ai.projects.models.VoiceResponseAudio + :ivar temperature: The sampling temperature used for the response. + :vartype temperature: float + :ivar created_at: The Unix timestamp (in seconds) for when the response was created. + :vartype created_at: ~datetime.datetime + :ivar completed_at: The Unix timestamp (in seconds) for when the response completed. + :vartype completed_at: ~datetime.datetime """ + id: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """The unique ID of the response, will look like ``resp_1234``.""" object: Optional[Literal["realtime.response"]] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -32069,12 +32480,24 @@ class VoiceResponseBase(_Model): # pylint: disable=docstring-keyword-should-mat visibility=["read", "create", "update", "delete", "query"] ) """Additional details about the status.""" + output: Optional[list["_models.RealtimeConversationItem"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """The list of output items generated by the response.""" + metadata: Optional["_models.Metadata"] = rest_field(visibility=["read", "create", "update", "delete", "query"]) usage: Optional["_models.RealtimeResponseUsage"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) """Usage statistics for the Response, this will correspond to billing. A Realtime API session will maintain a conversation context and append new Items to the Conversation, thus output from previous turns (text and audio tokens) will become the input for later turns.""" + conversation_id: Optional[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """Which conversation the response is added to, determined by the ``conversation`` field in the + ``response.create`` event. If ``auto``, the response will be added to the default conversation + and the value of ``conversation_id`` will be an id like ``conv_1234``. If ``none``, the + response will not be added to any conversation and the value of ``conversation_id`` will be + ``null``. If responses are being triggered automatically by VAD the response will be added to + the default conversation.""" output_modalities: Optional[list[Literal["text", "audio"]]] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) @@ -32086,98 +32509,11 @@ class VoiceResponseBase(_Model): # pylint: disable=docstring-keyword-should-mat ) """Maximum number of output tokens for a single assistant response, inclusive of tool calls, that was used in this response. Is either a int type or a Literal[\"inf\"] type.""" - - @overload - def __init__( - self, - *, - object: Optional[Literal["realtime.response"]] = None, - status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] = None, - status_details: Optional["_models.RealtimeResponseStatusDetails"] = None, - usage: Optional["_models.RealtimeResponseUsage"] = None, - output_modalities: Optional[list[Literal["text", "audio"]]] = None, - max_output_tokens: Optional[Union[int, Literal["inf"]]] = None, - ) -> None: ... - - @overload - def __init__(self, mapping: Mapping[str, Any]) -> None: - """ - :param mapping: raw JSON to initialize the model. - :type mapping: Mapping[str, Any] - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - -class VoiceResponse(VoiceResponseBase): # pylint: disable=docstring-keyword-should-match-keyword-only - """A persisted voice response representing one model inference turn within a conversation. In list - results the ``output`` projection may be omitted; retrieve the full response (``GET - .../responses/{response_id}``) or the paged response-items route (``GET - .../responses/{response_id}/items``) for its output items. ``created_at``/``completed_at`` are - Foundry durable ordering extensions. - - :ivar object: The object type, must be ``realtime.response``. Default value is - "realtime.response". - :vartype object: str - :ivar status: The final status of the response (``completed``, ``cancelled``, ``failed``, or - ``incomplete``, ``in_progress``). Is one of the following types: Literal["completed"], - Literal["cancelled"], Literal["failed"], Literal["incomplete"], Literal["in_progress"] - :vartype status: str or str or str or str or str - :ivar status_details: Additional details about the status. - :vartype status_details: ~azure.ai.projects.models.RealtimeResponseStatusDetails - :ivar usage: Usage statistics for the Response, this will correspond to billing. A Realtime API - session will maintain a conversation context and append new Items to the Conversation, thus - output from previous turns (text and audio tokens) will become the input for later turns. - :vartype usage: ~azure.ai.projects.models.RealtimeResponseUsage - :ivar output_modalities: The set of modalities the model used to respond, currently the only - possible values are ``[\\"audio\\"]``, ``[\\"text\\"]``. Audio output always include a text - transcript. Setting the output to mode ``text`` will disable audio output from the model. - :vartype output_modalities: list[str or str] - :ivar max_output_tokens: Maximum number of output tokens for a single assistant response, - inclusive of tool calls, that was used in this response. Is either a int type or a - Literal["inf"] type. - :vartype max_output_tokens: int or str - :ivar id: The unique id of the response. Required. - :vartype id: str - :ivar output: The output items produced by the response. May be omitted in list results; - retrieve the full response (GET .../responses/{response_id}) or use the paged response-items - route (GET .../responses/{response_id}/items) for its output items. Each item's ``response_id`` - also links it back to this response in the conversation-level items list. - :vartype output: list[~azure.ai.projects.models.RealtimeConversationItem] - :ivar conversation_id: The id of the conversation this response belongs to. Required. - :vartype conversation_id: str - :ivar audio: The audio configuration used for the response, including the voice and audio - format used for output. - :vartype audio: ~azure.ai.projects.models.VoiceResponseAudio - :ivar metadata: A set of key-value pairs attached to the response. - :vartype metadata: dict[str, str] - :ivar temperature: The sampling temperature used for the response. - :vartype temperature: float - :ivar created_at: The Unix timestamp (in seconds) for when the response was created. - :vartype created_at: ~datetime.datetime - :ivar completed_at: The Unix timestamp (in seconds) for when the response completed. - :vartype completed_at: ~datetime.datetime - """ - - id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The unique id of the response. Required.""" - output: Optional[list["_models.RealtimeConversationItem"]] = rest_field( - visibility=["read", "create", "update", "delete", "query"] - ) - """The output items produced by the response. May be omitted in list results; retrieve the full - response (GET .../responses/{response_id}) or use the paged response-items route (GET - .../responses/{response_id}/items) for its output items. Each item's ``response_id`` also links - it back to this response in the conversation-level items list.""" - conversation_id: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """The id of the conversation this response belongs to. Required.""" audio: Optional["_models.VoiceResponseAudio"] = rest_field( visibility=["read", "create", "update", "delete", "query"] ) """The audio configuration used for the response, including the voice and audio format used for output.""" - metadata: Optional[dict[str, str]] = rest_field(visibility=["read", "create", "update", "delete", "query"]) - """A set of key-value pairs attached to the response.""" temperature: Optional[float] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """The sampling temperature used for the response.""" created_at: Optional[datetime.datetime] = rest_field( @@ -32193,17 +32529,17 @@ class VoiceResponse(VoiceResponseBase): # pylint: disable=docstring-keyword-sho def __init__( self, *, - id: str, # pylint: disable=redefined-builtin - conversation_id: str, + id: Optional[str] = None, # pylint: disable=redefined-builtin object: Optional[Literal["realtime.response"]] = None, status: Optional[Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]] = None, status_details: Optional["_models.RealtimeResponseStatusDetails"] = None, + output: Optional[list["_models.RealtimeConversationItem"]] = None, + metadata: Optional["_models.Metadata"] = None, usage: Optional["_models.RealtimeResponseUsage"] = None, + conversation_id: Optional[str] = None, output_modalities: Optional[list[Literal["text", "audio"]]] = None, max_output_tokens: Optional[Union[int, Literal["inf"]]] = None, - output: Optional[list["_models.RealtimeConversationItem"]] = None, audio: Optional["_models.VoiceResponseAudio"] = None, - metadata: Optional[dict[str, str]] = None, temperature: Optional[float] = None, created_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_patch.py index e59db24760a7..ceec269ca869 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_patch.py @@ -12,12 +12,11 @@ from typing import Final, FrozenSet, List, Dict, Mapping, Optional, Any, Tuple from azure.core.polling import LROPoller, AsyncLROPoller, PollingMethod, AsyncPollingMethod from azure.core.polling.base_polling import ( - LROBasePolling, OperationFailed, _raise_if_bad_http_status_and_method, ) -from azure.core.polling.async_base_polling import AsyncLROBasePolling from azure.core.utils import case_insensitive_dict +from .._utils.polling import AIProjectsLROBasePolling, AsyncAIProjectsLROBasePolling from ._patch_evaluation_typeddicts import ( AzureAIAgentTargetParam, AzureAIBenchmarkPreviewEvalRunDataSource, @@ -63,6 +62,7 @@ _AgentDefinitionOptInKeys.DIGITAL_WORKER_V1_PREVIEW.value, _AgentDefinitionOptInKeys.GITHUB_COPILOT_V1_PREVIEW.value, _AgentDefinitionOptInKeys.SKILLS_V1_PREVIEW.value, + _AgentDefinitionOptInKeys.MICROSOFT_COPILOT_V1_PREVIEW.value, _FoundryFeaturesOptInKeys.MODEL_ROUTER_CONTROLS_V1_PREVIEW.value, ] ) @@ -178,7 +178,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: _FAILED: Final[FrozenSet[str]] = frozenset(["failed"]) -class _UpdateMemoriesLROPollingMethod(LROBasePolling): +class _UpdateMemoriesLROPollingMethod(AIProjectsLROBasePolling): """A custom polling method implementation for Memory Store updates.""" @property @@ -249,7 +249,7 @@ def _poll(self) -> None: _raise_if_bad_http_status_and_method(self._pipeline_response.http_response) -class _AsyncUpdateMemoriesLROPollingMethod(AsyncLROBasePolling): +class _AsyncUpdateMemoriesLROPollingMethod(AsyncAIProjectsLROBasePolling): """A custom polling method implementation for Memory Store updates.""" @property @@ -591,6 +591,15 @@ def __init__(self, client: Any, initial_response: Any, deserialization_callback: self._job_id = DatasetGenerationLROPoller._get_job_id(initial_response) super().__init__(client, initial_response, deserialization_callback, polling_method) + def status(self) -> str: + """Return the job status using the service spelling ``cancelled``. + + :return: The current job status. + :rtype: str + """ + status = super().status() + return "cancelled" if status.lower() == "canceled" else status + @property def details(self) -> Mapping[str, Any]: """Returns metadata associated with the agent optimization job operation. @@ -628,6 +637,15 @@ def __init__(self, client: Any, initial_response: Any, deserialization_callback: super().__init__(client, initial_response, deserialization_callback, polling_method) self._job_id = DatasetGenerationLROPoller._get_job_id(initial_response) + def status(self) -> str: + """Return the job status using the service spelling ``cancelled``. + + :return: The current job status. + :rtype: str + """ + status = super().status() + return "cancelled" if status.lower() == "canceled" else status + @property def details(self) -> Mapping[str, Any]: """Returns metadata associated with the agent optimization job operation. diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py index ae6b4c79b4ad..31880837c921 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py @@ -23513,8 +23513,8 @@ def list_bindings( :param agent_name: The name of the voice agent whose bindings are listed. Required. :type agent_name: str - :keyword provider: Filters bindings by provider. Known values are: "teams_phone_extension" and - "twilio". Default value is None. + :keyword provider: Filters bindings by provider. Known values are: "teams_phone_extensibility" + and "twilio". Default value is None. :paramtype provider: str or ~azure.ai.projects.models.TelephonyProvider :keyword status: Filters bindings by lifecycle status. Known values are: "active" and "suspended". Default value is None. @@ -23974,7 +23974,7 @@ def list_calls( :param agent_name: The name of the voice agent whose calls are listed. Required. :type agent_name: str - :keyword provider: Filters calls by provider. Known values are: "teams_phone_extension" and + :keyword provider: Filters calls by provider. Known values are: "teams_phone_extensibility" and "twilio". Default value is None. :paramtype provider: str or ~azure.ai.projects.models.TelephonyProvider :keyword status: Filters calls by lifecycle status. Known values are: "in_progress", "success", diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agent_insights.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agent_insights.py index 37628634f2fa..fb72238bc880 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agent_insights.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agent_insights.py @@ -12,7 +12,6 @@ from collections.abc import MutableMapping from azure.core.polling import NoPolling, PollingMethod -from azure.core.polling.base_polling import LROBasePolling from azure.core.tracing.decorator import distributed_trace from azure.core.utils import case_insensitive_dict @@ -21,18 +20,12 @@ ) from .. import models as _models from .._utils.model_base import _deserialize +from .._utils.polling import AIProjectsLROBasePolling from ..models import AgentInsightRunLROPoller JSON = MutableMapping[str, Any] -class _AgentInsightPolling(LROBasePolling): - def status(self) -> str: - status = super().status() - # Azure Core uses the single-L spelling for terminal cancellation. - return "canceled" if status.lower() == "cancelled" else status - - class BetaAgentInsightMonitorsOperations(BetaAgentInsightMonitorsOperationsGenerated): """Custom operations for beta Agent Insights monitors.""" @@ -135,7 +128,7 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: PollingMethod = cast( PollingMethod, - _AgentInsightPolling( + AIProjectsLROBasePolling( lro_delay, path_format_arguments=path_format_arguments, headers=headers, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agents.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agents.py index b2c0e82ef7ff..9c64d1588eeb 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agents.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_agents.py @@ -13,7 +13,6 @@ from typing import Union, Optional, Any, IO, cast, overload from azure.core.exceptions import HttpResponseError from azure.core.polling import NoPolling, PollingMethod -from azure.core.polling.base_polling import LROBasePolling from azure.core.tracing.decorator import distributed_trace from azure.core.utils import case_insensitive_dict from ._operations import ( @@ -24,6 +23,7 @@ ) from .. import models as _models from .._utils.model_base import _deserialize +from .._utils.polling import AIProjectsLROBasePolling from ..models import AgentOptimizationLROPoller from ..models._patch import ( _FOUNDRY_FEATURES_HEADER_NAME, @@ -449,7 +449,8 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: PollingMethod = cast( - PollingMethod, LROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs) + PollingMethod, + AIProjectsLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs), ) elif polling is False: polling_method = cast(PollingMethod, NoPolling()) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_datasets.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_datasets.py index 4d0778e737b8..b034292b1746 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_datasets.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_datasets.py @@ -18,11 +18,11 @@ from azure.storage.blob import ContainerClient from azure.core.paging import ItemPaged from azure.core.polling import NoPolling, PollingMethod -from azure.core.polling.base_polling import LROBasePolling from azure.core.tracing.decorator import distributed_trace from ._operations import DatasetsOperations as DatasetsOperationsGenerated from .. import models as _models from .._utils.model_base import _deserialize +from .._utils.polling import AIProjectsLROBasePolling from ..models import DatasetGenerationLROPoller from ..models._patch import ( _DATA_GENERATION_JOBS_FEATURE_HEADER, @@ -143,7 +143,7 @@ def get_long_running_output(pipeline_response): if polling is True: polling_method: PollingMethod = cast( PollingMethod, - LROBasePolling( + AIProjectsLROBasePolling( lro_delay, path_format_arguments=path_format_arguments, headers=polling_headers, **kwargs ), ) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_evaluators.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_evaluators.py index 730b639c8a78..6ce64e6b43bf 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_evaluators.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_evaluators.py @@ -9,13 +9,13 @@ from typing import Any, IO, Optional, Union, cast, overload from azure.core.polling import NoPolling, PollingMethod -from azure.core.polling.base_polling import LROBasePolling from azure.core.tracing.decorator import distributed_trace from azure.core.utils import case_insensitive_dict from ._operations import EvaluatorsOperations as EvaluatorsOperationsGenerated from .. import models as _models from .._utils.model_base import _deserialize +from .._utils.polling import AIProjectsLROBasePolling from ..models import EvaluatorGenerationLROPoller JSON = MutableMapping[str, Any] @@ -76,9 +76,7 @@ def begin_create_generation_job( headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) params = kwargs.pop("params", {}) or {} - content_type: Optional[str] = kwargs.pop( - "content_type", headers.pop("Content-Type", None) - ) + content_type: Optional[str] = kwargs.pop("content_type", headers.pop("Content-Type", None)) cls = kwargs.pop("cls", None) polling: Union[bool, PollingMethod] = kwargs.pop("polling", True) lro_delay = kwargs.pop("polling_interval", self._config.polling_interval) @@ -103,29 +101,21 @@ def get_long_running_output(pipeline_response): response_headers["Operation-Location"] = self._deserialize( "str", response.headers.get("Operation-Location") ) - response_headers["Location"] = self._deserialize( - "str", response.headers.get("Location") - ) + response_headers["Location"] = self._deserialize("str", response.headers.get("Location")) - deserialized = _deserialize( - _models.EvaluatorVersion, response.json().get("result", {}) - ) + deserialized = _deserialize(_models.EvaluatorVersion, response.json().get("result", {})) if cls: return cls(pipeline_response, deserialized, response_headers) return deserialized path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), + "endpoint": self._serialize.url("self._config.endpoint", self._config.endpoint, "str", skip_quote=True), } if polling is True: polling_method: PollingMethod = cast( PollingMethod, - LROBasePolling( - lro_delay, path_format_arguments=path_format_arguments, **kwargs - ), + AIProjectsLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs), ) elif polling is False: polling_method = cast(PollingMethod, NoPolling()) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py index c23a994b795e..8cb4b6b95a75 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py @@ -198,6 +198,9 @@ def __init__(self) -> None: # and have a parameter that specifies the version to use. self._impl = _AIAgentsInstrumentorPreview() self._responses_impl = _ResponsesInstrumentorPreview() + from ._voice_agents_instrumentor import _VoiceAgentsInstrumentor # pylint: disable=import-outside-toplevel + + self._voice_agents_impl = _VoiceAgentsInstrumentor(self._impl, self._responses_impl) def instrument( self, @@ -229,7 +232,8 @@ def instrument( called without uninstrument being called in between the instrument calls. :type enable_content_recording: bool, optional :param enable_trace_context_propagation: Whether to enable automatic trace context propagation - to OpenAI SDK HTTP requests. When enabled, traceparent and tracestate headers will be injected + to OpenAI SDK HTTP requests and Voice Agents WebSocket handshakes. When enabled, + traceparent and tracestate headers will be injected into requests made by OpenAI clients obtained via get_openai_client(), allowing server-side spans to be correlated with client-side spans. `True` will enable it, `False` will disable it. If no value is provided, then the value read from environment variable @@ -237,6 +241,7 @@ def instrument( variable is not found, then the value will default to `True`. Note: Changing this value only affects OpenAI clients obtained via get_openai_client() after the change; previously acquired clients are unaffected. + Voice Agents evaluate this setting when each realtime connection is opened. :type enable_trace_context_propagation: bool, optional :param enable_baggage_propagation: Whether to include baggage headers in trace context propagation. Only applies when enable_trace_context_propagation is True. `True` will enable baggage propagation, @@ -248,6 +253,8 @@ def instrument( context propagation hook registered at acquisition time (i.e. clients obtained via get_openai_client() while enable_trace_context_propagation was True). Clients acquired when trace context propagation was disabled will never propagate baggage regardless of this value. + For Voice Agents, baggage is evaluated when the WebSocket handshake is sent; existing + connections are unaffected. :type enable_baggage_propagation: bool, optional """ @@ -262,6 +269,7 @@ def instrument( self._impl.instrument(enable_content_recording, enable_trace_context_propagation, enable_baggage_propagation) self._responses_impl.instrument(enable_content_recording) + self._voice_agents_impl.instrument() def uninstrument(self) -> None: """ @@ -270,6 +278,7 @@ def uninstrument(self) -> None: This method removes any active instrumentation, stopping the tracing of AIProjectClient methods. """ + self._voice_agents_impl.uninstrument() self._impl.uninstrument() self._responses_impl.uninstrument() diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_utils.py b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_utils.py index 47047e2720c3..c07f6e93533f 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_utils.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_utils.py @@ -251,10 +251,11 @@ def start_span( if model: span.add_attribute(GEN_AI_REQUEST_MODEL, model) - if temperature: + # 0 is a valid value for these parameters, so only skip them when they are not set. + if temperature is not None: span.add_attribute(GEN_AI_REQUEST_TEMPERATURE, str(temperature)) - if top_p: + if top_p is not None: span.add_attribute(GEN_AI_REQUEST_TOP_P, str(top_p)) if max_prompt_tokens: diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_voice_agents_instrumentor.py b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_voice_agents_instrumentor.py new file mode 100644 index 000000000000..923e107c93e4 --- /dev/null +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_voice_agents_instrumentor.py @@ -0,0 +1,1017 @@ +# pylint: disable=too-many-lines +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Opt-in Voice Agents tracing using the existing Foundry tracer and settings.""" + +from asyncio import CancelledError # pylint: disable=do-not-import-asyncio +import functools +import importlib +import inspect +import json +import logging +import threading +import time +import weakref +from collections import deque +from collections.abc import Mapping +from contextlib import contextmanager, nullcontext +from typing import Any, Callable, Optional +from urllib.parse import urlsplit + +from azure.core.settings import settings +from azure.core.tracing import AbstractSpan + +from ._utils import ( + ERROR_TYPE, + GEN_AI_AGENT_ID, + GEN_AI_AGENT_NAME, + GEN_AI_AGENT_TYPE, + GEN_AI_AGENT_VERSION, + GEN_AI_CONVERSATION_ID, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_ID, + GEN_AI_SYSTEM_MESSAGE, + GEN_AI_TOOL_CALL_ID, + GEN_AI_TOOL_NAME, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + OperationName, + start_span, +) +from . import _responses_instrumentor + +_LOGGER = logging.getLogger("azure.ai.projects.realtime") +_PREFIX = "az.ai.voice_agents." +_MAX_RESPONSES = 64 +_MAX_PARTS = 32 +_MAX_CONTENT = 4096 +_ERROR_TYPES = { + "TypeError", + "ValueError", + "RuntimeError", + "ConnectionError", + "ConnectionResetError", + "TimeoutError", + "ClientAuthenticationError", + "HttpResponseError", + "ResourceNotFoundError", + "ServiceRequestError", + "ServiceResponseError", + "invalid_request_error", + "server_error", + "authentication_error", + "rate_limit_error", +} +_ERROR_CODES = { + "agent_disabled", + "invalid_request", + "invalid_request_error", + "invalid_event", + "invalid_state", + "rate_limit_exceeded", + "insufficient_quota", + "session_expired", + "authentication_error", + "permission_denied", + "server_error", +} +_ORIGINALS: dict[tuple[type, str], Callable] = {} +_SESSIONS: weakref.WeakSet = weakref.WeakSet() + + +def _record(action: Callable[[], Any]) -> Any: + # Exporter/provider failures must not change service results or expose their messages. + try: + return action() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Unable to record Voice Agents telemetry.") + return None + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _limited(value: Any) -> Optional[str]: + return value[:_MAX_CONTENT] if isinstance(value, str) else None + + +def _identifier(value: Any) -> Optional[str]: + return value if isinstance(value, str) and 0 < len(value) <= _MAX_CONTENT else None + + +def _recording(span: Optional[AbstractSpan]) -> bool: + return span is not None and span.span_instance.is_recording() + + +def _attributes(span: Optional[AbstractSpan], values: Mapping[str, Any]) -> None: + if span is not None and _recording(span): + for key, value in values.items(): + if value is not None: + _record(functools.partial(span.add_attribute, key, value)) + + +def _event(span: Optional[AbstractSpan], name: str, values: Optional[dict[str, Any]] = None) -> None: + if span is not None and _recording(span): + span.span_instance.add_event( + name, attributes={key: val for key, val in (values or {}).items() if val is not None} + ) + + +def _context(span: Optional[AbstractSpan]) -> Any: + if span is None: + return nullcontext() + from opentelemetry.trace import use_span # pylint: disable=import-outside-toplevel + + return use_span(span.span_instance, end_on_exit=False, record_exception=False, set_status_on_exception=False) + + +def _start(name: str, endpoint: str, parent: Optional[AbstractSpan] = None) -> Optional[AbstractSpan]: + parsed = urlsplit(endpoint) + with _context(parent): + span = start_span( + OperationName.CREATE_AGENT if name == "create_agent" else OperationName.RESPONSES, + server_address=parsed.hostname, + port=parsed.port, + span_name=name, + ) + if name in ("create_agent", "invoke_agent"): + _attributes(span, {GEN_AI_OPERATION_NAME: name, GEN_AI_AGENT_TYPE: "voice"}) + return span + + +def _error_attributes(error: Any) -> dict[str, Any]: + if isinstance(error, Mapping): + kind = error.get("type") + code = error.get("code") + result: dict[str, Any] = {ERROR_TYPE: kind if isinstance(kind, str) and kind in _ERROR_TYPES else "Error"} + if isinstance(code, str) and code in _ERROR_CODES: + result[_PREFIX + "error.code"] = code + return result + kind = type(error).__name__ + result = {ERROR_TYPE: kind if kind in _ERROR_TYPES else "Error"} + status = getattr(error, "status_code", None) + if isinstance(status, int): + result["http.response.status_code"] = status + return result + + +def _fail(span: Optional[AbstractSpan], error: BaseException) -> None: + if isinstance(error, (CancelledError, KeyboardInterrupt)): + _attributes(span, {_PREFIX + "cancelled": True}) + else: + _set_error(span, _error_attributes(error)) + + +def _set_error(span: Optional[AbstractSpan], attributes: Mapping[str, Any]) -> None: + if span is not None and _recording(span): + from opentelemetry.trace import StatusCode # pylint: disable=import-outside-toplevel + + _attributes(span, attributes) + span.span_instance.set_status(StatusCode.ERROR) + + +def _end(span: Optional[AbstractSpan]) -> None: + if span is not None: + span.span_instance.end() + + +@contextmanager +def _rest_context(span: AbstractSpan) -> Any: + # Reuse Core's nested-method suppression, but never pass raw exceptions to its exporter. + _record(span.__enter__) + try: + yield + except BaseException as error: + _record(lambda: _fail(span, error)) + raise + finally: + # Core's legacy annotation omits None for a normal context-manager exit. + _record(lambda: span.__exit__(None, None, None)) # type: ignore[arg-type] + + +def _audio_size(value: Any) -> int: + if isinstance(value, (bytes, bytearray)): + return len(value) + if isinstance(value, str): + # The protocol carries padded base64. Count bytes without allocating or retaining media. + return max(0, len(value) * 3 // 4 - (2 if value.endswith("==") else 1 if value.endswith("=") else 0)) + return 0 + + +def _part(value: Any, content: bool) -> Optional[dict[str, Any]]: + value = _mapping(value) + kind = value.get("type") + if kind not in ("input_text", "output_text", "text", "input_audio", "output_audio", "audio"): + return None + audio = "audio" in kind + result: dict[str, Any] = {"type": "audio" if audio else "text"} + if content: + text = _limited(value.get("transcript") if audio else value.get("text")) + if text is not None: + result["content"] = text + return result + + +def _message(value: Any, content: bool) -> Optional[dict[str, Any]]: + item = _mapping(value) + kind = item.get("type") + if kind in ("function_call", "function_call_output", "mcp_call", "system_tool"): + part: dict[str, Any] = {"type": "tool_call_response" if kind == "function_call_output" else "tool_call"} + if isinstance(item.get("call_id"), str): + part["id"] = _limited(item["call_id"]) + if content: + for source, target in (("name", "name"), ("arguments", "arguments"), ("output", "result")): + text = _limited(item.get(source)) + if text is not None: + part[target] = text + return {"role": "tool" if kind == "function_call_output" else "assistant", "parts": [part]} + if kind == "message": + parts = [_part(part, content) for part in (item.get("content") or [])[:_MAX_PARTS]] + parts = [part for part in parts if part is not None] + if parts: + role = item.get("role") + return {"role": role if role in ("system", "user") else "assistant", "parts": parts} + return None + + +def _without_content(message: dict[str, Any]) -> None: + for part in message["parts"]: + for name in ("content", "name", "arguments", "result"): + part.pop(name, None) + + +class _Response: + def __init__(self, span: Optional[AbstractSpan], model: Optional[str]) -> None: + self.span = span + self.model = model + self.started = time.perf_counter() + self.audio_bytes = 0 + self.first_audio: Optional[float] = None + self.first_text: Optional[float] = None + self.messages: dict[str, dict[str, Any]] = {} + self.parts: dict[tuple[str, int, str], dict[str, Any]] = {} + + +class _Session: # pylint: disable=too-many-instance-attributes + def __init__(self, instrumentor: "_VoiceAgentsInstrumentor", manager: Any) -> None: + self.instrumentor = instrumentor + self.endpoint = manager._endpoint # pylint: disable=protected-access + self.span = _start("voice_agents.realtime.session", self.endpoint) + self.connect_span = _record(lambda: _start("voice_agents.realtime.connect", self.endpoint, self.span)) + transport = manager._transport or "websocket" # pylint: disable=protected-access + safe_transport = transport if transport in ("websocket", "webrtc") else "other" + for span in (self.span, self.connect_span): + _attributes(span, {_PREFIX + "connection.transport": safe_transport}) + self.lock = threading.RLock() + self.responses: dict[str, _Response] = {} + self.model: Optional[str] = None + self.conversation_id: Optional[str] = None + self.input_bytes = 0 + self.output_bytes = 0 + self.finished = 0 + self.untracked = 0 + self.connected = False + self.ended = False + self.audio_summary_recorded = False + self.input_kind: Optional[str] = None + self.input_item_id: Optional[str] = None + self.input_item_pending = False + self.input_generation = 0 + self.pending_audio_commits: deque[int] = deque(maxlen=_MAX_PARTS) + self.untracked_audio_commits = 0 + self.committed_audio_ids: deque[str] = deque(maxlen=_MAX_PARTS) + self.audio_item_ids: deque[str] = deque(maxlen=_MAX_PARTS) + self.failure: Optional[dict[str, Any]] = None + self.cancelled = False + self.receive_cancelled = False + _SESSIONS.add(self) + + def opened(self) -> None: + self.connected = True + _end(self.connect_span) + self.connect_span = None + + def snapshot_sent(self, value: Any) -> dict[str, Any]: + event = _mapping(json.loads(value)) if isinstance(value, str) else _mapping(value) + kind = event.get("type") + summary: dict[str, Any] = {"type": kind if isinstance(kind, str) else None} + if kind == "input_audio_buffer.append": + summary["audio_bytes"] = _audio_size(event.get("audio")) + elif kind == "conversation.item.create": + item = _mapping(event.get("item")) + summary["item_id"] = _identifier(item.get("id")) + summary["input_audio"] = ( + item.get("type") == "message" + and item.get("role") == "user" + and any( + _mapping(part).get("type") in ("input_audio", "audio") + for part in (item.get("content") or [])[:_MAX_PARTS] + ) + ) + if _recording(self.span): + summary["message"] = _message(item, self.instrumentor.content()) + if item.get("type") == "function_call_output": + summary["call_id"] = _limited(item.get("call_id")) + summary["tool_output"] = True + return summary + + def sent(self, event: Mapping[str, Any]) -> None: + with self.lock: + if self.ended: + return + kind = event.get("type") + content = self.instrumentor.content() + if kind == "input_audio_buffer.append": + self.input_bytes += event["audio_bytes"] + if not self.audio_summary_recorded: + self.input_generation += 1 + self.input_kind = "audio" + self.input_item_pending = True + self._input_audio_summary() + self.audio_summary_recorded = True + elif kind in ("input_audio_buffer.commit", "input_audio_buffer.clear"): + self.audio_summary_recorded = False + if kind == "input_audio_buffer.commit" and self.input_kind == "audio": + if len(self.pending_audio_commits) == _MAX_PARTS: + self.untracked_audio_commits += 1 + self.pending_audio_commits.append(self.input_generation) + elif kind == "input_audio_buffer.clear": + self.input_item_pending = True + elif kind == "conversation.item.create": + self.audio_summary_recorded = False + self.input_generation += 1 + self.input_kind = "audio" if event.get("input_audio") else "message" + self.input_item_pending = True + identifier = _identifier(event.get("item_id")) + if self.input_kind == "audio" and identifier is not None: + self.input_item_id = identifier + self.input_item_pending = False + self.audio_item_ids.append(identifier) + summary = event.get("message") + if summary: + if not content: + _without_content(summary) + _attributes(self.span, {GEN_AI_INPUT_MESSAGES: json.dumps([summary])}) + if event.get("tool_output"): + _event(self.span, "voice_agents.tool.output_sent", {GEN_AI_TOOL_CALL_ID: event.get("call_id")}) + elif kind == "response.cancel": + _event(self.span, "voice_agents.response.cancel_requested") + elif kind == "session.update": + _event(self.span, "voice_agents.session.update_sent") + + def _input_audio_summary(self, transcript: Any = None) -> None: + part: dict[str, Any] = {"type": "audio"} + text = _limited(transcript) if self.instrumentor.content() else None + if text is not None: + part["content"] = text + _attributes(self.span, {GEN_AI_INPUT_MESSAGES: json.dumps([{"role": "user", "parts": [part]}])}) + + def _audio_boundary(self, event: Mapping[str, Any]) -> None: + identifier = _identifier(event.get("item_id")) + if identifier is None: + return + if event.get("type") == "input_audio_buffer.committed": + if identifier in self.committed_audio_ids: + return + self.committed_audio_ids.append(identifier) + if self.untracked_audio_commits: + self.untracked_audio_commits -= 1 + self.audio_item_ids.append(identifier) + return + if self.pending_audio_commits and self.pending_audio_commits.popleft() != self.input_generation: + self.audio_item_ids.append(identifier) + return + if self.input_kind == "message": + return + if identifier in self.audio_item_ids and (identifier != self.input_item_id or self.input_item_pending): + return + if identifier != self.input_item_id or self.input_item_pending: + self.input_kind = "audio" + self.input_item_id = identifier + self.input_item_pending = False + self.audio_item_ids.append(identifier) + self._input_audio_summary() + self.audio_summary_recorded = event.get("type") == "input_audio_buffer.speech_started" + + def received(self, value: Any) -> None: # pylint: disable=too-many-branches,too-many-statements + with self.lock: + if self.ended: + return + event = _mapping(value) + kind = event.get("type") + content = self.instrumentor.content() + response_id = event.get("response_id") + tracked = self.responses.get(response_id) if isinstance(response_id, str) else None + if kind in ("session.created", "session.updated"): + session = _mapping(event.get("session")) + self.model = _limited(session.get("model")) or self.model + self.conversation_id = _limited(event.get("conversation_id")) or self.conversation_id + _attributes( + self.span, + { + _PREFIX + "session.id": _limited(session.get("id")), + GEN_AI_CONVERSATION_ID: self.conversation_id, + GEN_AI_REQUEST_MODEL: self.model, + }, + ) + _event(self.span, "voice_agents." + kind) + audio = _mapping(session.get("audio")) + for direction in ("input", "output"): + config = _mapping(_mapping(audio.get(direction)).get("format")) + if config.get("type") in ("audio/pcm", "audio/pcmu", "audio/pcma"): + _attributes(self.span, {_PREFIX + direction + ".audio.format": config["type"]}) + if isinstance(config.get("rate"), int): + _attributes(self.span, {_PREFIX + direction + ".audio.sample_rate": config["rate"]}) + elif kind in ("input_audio_buffer.speech_started", "input_audio_buffer.committed"): + self._audio_boundary(event) + elif kind == "input_audio_buffer.cleared": + self.audio_summary_recorded = False + self.input_item_pending = True + elif kind == "response.created": + response = _mapping(event.get("response")) + identifier = _identifier(response.get("id")) + if identifier is not None and identifier not in self.responses: + if len(self.responses) >= _MAX_RESPONSES: + self.untracked += 1 + return + span = _start("invoke_agent", self.endpoint, self.span) + _attributes( + span, + { + GEN_AI_RESPONSE_ID: _limited(identifier), + GEN_AI_REQUEST_MODEL: self.model, + GEN_AI_CONVERSATION_ID: _limited(response.get("conversation_id")) or self.conversation_id, + }, + ) + self.responses[identifier] = _Response(span, self.model) + elif kind in ("response.output_audio.delta", "response.audio.delta"): + size = _audio_size(event.get("delta")) + self.output_bytes += size + if tracked: + tracked.audio_bytes += size + if size and tracked.first_audio is None: + tracked.first_audio = time.perf_counter() - tracked.started + elif kind in ("response.output_text.delta", "response.text.delta"): + if tracked and tracked.first_text is None and event.get("delta"): + tracked.first_text = time.perf_counter() - tracked.started + elif kind == "response.done": + self.complete(_mapping(event.get("response"))) + elif kind in ( + "response.output_text.done", + "response.text.done", + "response.output_audio_transcript.done", + "response.audio_transcript.done", + "response.output_audio.done", + "response.audio.done", + ): + if tracked and _recording(tracked.span): + is_audio = "audio" in kind + part: dict[str, Any] = {"type": "audio" if is_audio else "text"} + text = _limited(event.get("transcript") if is_audio else event.get("text")) + if content and text is not None: + part["content"] = text + item_id = _identifier(event.get("item_id")) + index = event.get("content_index", 0) + if item_id is not None and isinstance(index, int): + key = (item_id, index, part["type"]) + if key in tracked.parts: + tracked.parts[key].update(part) + elif len(tracked.parts) < _MAX_PARTS: + tracked.parts[key] = part + elif kind in ("response.function_call_arguments.done", "response.system_tool_arguments.done"): + span = tracked.span if tracked else self.span + attributes = { + GEN_AI_TOOL_CALL_ID: _limited(event.get("call_id")), + _PREFIX + "tool.kind": "function" if kind == "response.function_call_arguments.done" else "system", + } + if content: + attributes[GEN_AI_TOOL_NAME] = _limited(event.get("name")) + attributes[_PREFIX + "tool.arguments"] = _limited(event.get("arguments")) + _event( + span, + "voice_agents.tool.requested", + {key: val for key, val in attributes.items() if val is not None}, + ) + summary = _message({**event, "type": "function_call"}, content) if _recording(span) else None + call_id = _identifier(event.get("call_id")) + if tracked and summary and call_id is not None and len(tracked.messages) < _MAX_PARTS: + tracked.messages["tool:" + call_id] = summary + elif kind in ( + "response.mcp_call.completed", + "response.mcp_call.failed", + "session.end_conversation.completed", + ): + _event(self.span, kind, {_PREFIX + "item.id": _limited(event.get("item_id"))}) + elif kind in ("error", "rtc.call.error"): + _event( + self.span, + "voice_agents.server_error" if kind == "error" else kind, + _error_attributes(_mapping(event.get("error"))), + ) + elif kind == "warning": + _event(self.span, "voice_agents.warning") + elif kind in ( + "conversation.item.input_audio_transcription.completed", + "conversation.item.input_audio_transcription.failed", + ): + # Late transcription must not replace a newer message or an unconfirmed audio turn. + if ( + self.input_kind == "audio" + and not self.input_item_pending + and self.input_item_id == _identifier(event.get("item_id")) + ): + self._input_audio_summary(event.get("transcript") if kind.endswith("completed") else None) + _event(self.span, "voice_agents.transcription." + kind.rsplit(".", 1)[-1]) + elif kind in ("session.subagent.started", "session.subagent.completed", "session.subagent.aborted"): + attributes = {_PREFIX + "subagent.consultation.id": _limited(event.get("consultation_id"))} + if event.get("reason") in ( + "unknown_target", + "timeout", + "cancelled", + "stopped_by_user", + "superseded", + "failed", + ): + attributes[_PREFIX + "subagent.abort_reason"] = event["reason"] + _event(self.span, kind, {key: val for key, val in attributes.items() if val is not None}) + + def _output_parts(self, tracked: _Response, item_id: str, content: list[Any]) -> list[dict[str, Any]]: + parts = [] + for index, value in enumerate(content[:_MAX_PARTS]): + part = _part(value, self.instrumentor.content()) + if part is None: + continue + observed = tracked.parts.get((item_id, index, part["type"])) + if self.instrumentor.content() and observed and "content" in observed: + part.setdefault("content", observed["content"]) + parts.append(part) + return parts + + def flush(self, tracked: _Response, response: Optional[Mapping[str, Any]] = None) -> None: + _attributes( + tracked.span, + { + _PREFIX + "output.audio.bytes": tracked.audio_bytes, + _PREFIX + "response.time_to_first_audio_seconds": tracked.first_audio, + _PREFIX + "response.time_to_first_text_seconds": tracked.first_text, + }, + ) + if not _recording(tracked.span): + return + fallback = dict(tracked.messages) + for (item_id, _, _), part in sorted(tracked.parts.items()): + message = fallback.setdefault(item_id, {"role": "assistant", "parts": []}) + message["parts"].append(part) + messages = [] + unkeyed = False + for item in (_mapping(response).get("output") or [])[:_MAX_PARTS]: + summary = _message(item, self.instrumentor.content()) + if summary is None: + continue + item = _mapping(item) + call_id = _identifier(item.get("call_id")) + key = ( + "tool:" + call_id + if item.get("type") in ("function_call", "system_tool", "mcp_call") and call_id is not None + else _identifier(item.get("id")) + ) + if key: + fallback.pop(key, None) + if item.get("type") == "message": + summary["parts"] = self._output_parts(tracked, key, item.get("content") or []) + else: + unkeyed = True + messages.append(summary) + if not unkeyed: + messages.extend(fallback.values()) + if not self.instrumentor.content(): + for message in messages: + _without_content(message) + if messages: + _attributes(tracked.span, {GEN_AI_OUTPUT_MESSAGES: json.dumps(messages[:_MAX_PARTS])}) + + def complete(self, response: Mapping[str, Any]) -> None: + identifier = response.get("id") + tracked = self.responses.pop(identifier, None) if isinstance(identifier, str) else None + if tracked is None: + return + usage = _mapping(response.get("usage")) + status = response.get("status") + status = status if status in ("completed", "cancelled", "failed", "incomplete", "in_progress") else "unknown" + try: + _attributes(tracked.span, {_PREFIX + "response.status": status}) + for key, attribute in ( + ("input_tokens", GEN_AI_USAGE_INPUT_TOKENS), + ("output_tokens", GEN_AI_USAGE_OUTPUT_TOKENS), + ): + if isinstance(usage.get(key), int): + _attributes(tracked.span, {attribute: usage[key]}) + modalities = response.get("output_modalities") + if isinstance(modalities, list) and all(value in ("text", "audio") for value in modalities): + _attributes(tracked.span, {_PREFIX + "response.output_modalities": modalities}) + self.flush(tracked, response) + if status == "failed": + _fail(tracked.span, RuntimeError()) + _attributes(tracked.span, {ERROR_TYPE: "response_failed"}) + elif status == "completed" and tracked.span is not None and _recording(tracked.span): + from opentelemetry.trace import StatusCode # pylint: disable=import-outside-toplevel + + tracked.span.span_instance.set_status(StatusCode.OK) + elif status == "cancelled": + _attributes(tracked.span, {_PREFIX + "cancelled": True}) + finally: + _record(lambda: _end(tracked.span)) + self.finished += 1 + _record(lambda: self.metrics(tracked, usage, "response_failed" if status == "failed" else None)) + + def metrics(self, tracked: _Response, usage: Mapping[str, Any], error: Optional[str] = None) -> None: + parsed = urlsplit(self.endpoint) + metrics = self.instrumentor.metrics + with _context(tracked.span): + metrics._record_operation_duration( # pylint: disable=protected-access + time.perf_counter() - tracked.started, + "invoke_agent", + parsed.hostname, + parsed.port, + tracked.model, + error, + ) + for key, token_type in (("input_tokens", "input"), ("output_tokens", "completion")): + count = usage.get(key) + if isinstance(count, int) and count >= 0: + metrics._record_token_usage( # pylint: disable=protected-access + count, token_type, "invoke_agent", parsed.hostname, tracked.model + ) + + def fail(self, error: BaseException) -> None: + with self.lock: + if isinstance(error, (CancelledError, KeyboardInterrupt)): + self.cancelled = True + else: + self.failure = _error_attributes(error) + _record(functools.partial(_fail, self.span, error)) + + def _mark_error(self, span: Optional[AbstractSpan]) -> None: + if self.failure is not None: + _set_error(span, self.failure) + elif self.cancelled: + _attributes(span, {_PREFIX + "cancelled": True}) + + def close(self, code: Optional[int] = None, clean: bool = True, error: Optional[BaseException] = None) -> None: + with self.lock: + if self.ended: + return + self.ended = True + _SESSIONS.discard(self) + if error: + self.fail(error) + _record(functools.partial(self._mark_error, self.connect_span)) + _record(lambda: _end(self.connect_span)) + self.connect_span = None + for tracked in self.responses.values(): + _record(functools.partial(self.flush, tracked)) + _attributes(tracked.span, {_PREFIX + "response.status": "interrupted"}) + _record(functools.partial(self._mark_error, tracked.span)) + _record(functools.partial(_end, tracked.span)) + _record(functools.partial(self.metrics, tracked, {}, "response_interrupted" if self.failure else None)) + self.responses.clear() + _attributes( + self.span, + { + _PREFIX + "connected": self.connected, + _PREFIX + "close.code": code if isinstance(code, int) and 1000 <= code <= 4999 else None, + _PREFIX + "close.was_clean": clean, + _PREFIX + "input.audio.bytes": self.input_bytes, + _PREFIX + "output.audio.bytes": self.output_bytes, + _PREFIX + "responses.finished": self.finished, + _PREFIX + "responses.untracked": self.untracked or None, + }, + ) + _record(lambda: _end(self.span)) + + +class _VoiceAgentsInstrumentor: + def __init__(self, owner: Any, metrics: Any) -> None: + self.owner = owner + self.metrics = metrics + + def enabled(self, kwargs: Optional[dict[str, Any]] = None) -> bool: + requested = _mapping((kwargs or {}).get("tracing_options")).get("enabled") + return ( + self.owner.is_instrumented() + and (requested is True or settings.tracing_enabled()) + and settings.tracing_implementation() is not None + and requested is not False + ) + + def content(self) -> bool: + return self.owner.is_content_recording_enabled() + + def _rest(self, function: Callable, operation: str, voice_only: bool = False) -> Callable: + signature = inspect.signature(function) + + def begin(args: tuple, kwargs: dict[str, Any]) -> Optional[AbstractSpan]: + if not self.enabled(kwargs): + return None + bound = signature.bind_partial(*args, **kwargs).arguments + definition = _mapping(bound.get("definition")) or _mapping(_mapping(bound.get("body")).get("definition")) + if voice_only and definition.get("kind") != "voice": + return None + endpoint = args[0]._config.endpoint # pylint: disable=protected-access + span = _start(operation, endpoint) + _attributes( + span, + { + GEN_AI_REQUEST_MODEL: _limited(definition.get("model")), + GEN_AI_AGENT_NAME: _limited(bound.get("agent_name")), + _PREFIX + "operation": operation.rsplit(".", 1)[-1] if operation != "create_agent" else None, + }, + ) + instructions = _limited(definition.get("instructions")) if self.content() else None + if instructions is not None: + _attributes(span, {GEN_AI_SYSTEM_MESSAGE: json.dumps([{"type": "text", "content": instructions}])}) + return span + + def finish(span: Optional[AbstractSpan], result: Any) -> None: + if operation == "create_agent": + _attributes( + span, + { + GEN_AI_AGENT_ID: _limited(getattr(result, "id", None)), + GEN_AI_AGENT_VERSION: _limited(getattr(result, "version", None)), + GEN_AI_AGENT_NAME: _limited(getattr(result, "name", None)), + }, + ) + latest = _mapping(_mapping(getattr(result, "versions", None)).get("latest")) + definition = _mapping(latest.get("definition")) + _attributes(span, {GEN_AI_REQUEST_MODEL: _limited(definition.get("model"))}) + + # create_version already has a Foundry wrapper. Bypass only that wrapper for voice + # definitions; non-voice creation continues through its original instrumentation. + original = getattr(function, "_original", function) if voice_only else function + + def untraced(args: tuple, kwargs: dict[str, Any]) -> Callable: + if voice_only: + definition = _mapping(kwargs.get("definition")) + body = kwargs.get("body", args[2] if len(args) > 2 else None) + definition = definition or _mapping(_mapping(body).get("definition")) + if definition.get("kind") == "voice": + return original + return function + + if inspect.iscoroutinefunction(function): + + @functools.wraps(function) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + span = _record(lambda: begin(args, kwargs)) + if span is None: + return await untraced(args, kwargs)(*args, **kwargs) + with _rest_context(span): + result = await original(*args, **{**kwargs, "merge_span": True}) + _record(lambda: finish(span, result)) + return result + + return async_wrapper + + @functools.wraps(function) + def wrapper(*args: Any, **kwargs: Any) -> Any: + span = _record(lambda: begin(args, kwargs)) + if span is None: + return untraced(args, kwargs)(*args, **kwargs) + with _rest_context(span): + result = original(*args, **{**kwargs, "merge_span": True}) + _record(lambda: finish(span, result)) + return result + + return wrapper + + def _enter(self, function: Callable) -> Callable: + def prepare(manager: Any) -> Optional[_Session]: + return _Session(self, manager) if self.enabled() else None + + def headers(manager: Any, session: Optional[_Session]) -> dict[str, str]: + from ._ai_project_instrumentor import ( # pylint: disable=import-outside-toplevel + _trace_context_propagation_enabled, + _trace_context_baggage_propagation_enabled, + ) + + original = manager._extra_headers # pylint: disable=protected-access + if session and _trace_context_propagation_enabled: + from opentelemetry import propagate # pylint: disable=import-outside-toplevel + + carrier: dict[str, str] = {} + propagate.inject(carrier) + allowed = {"traceparent", "tracestate"} + if _trace_context_baggage_propagation_enabled: + allowed.add("baggage") + copied = dict(original) + present = {key.lower() for key in copied} + copied.update( + { + key: value + for key, value in carrier.items() + if key.lower() in allowed and key.lower() not in present + } + ) + manager._extra_headers = copied # pylint: disable=protected-access + return original + + if inspect.iscoroutinefunction(function): + + @functools.wraps(function) + async def async_wrapper(manager: Any) -> Any: + session = _record(lambda: prepare(manager)) + original_headers = manager._extra_headers # pylint: disable=protected-access + try: + with _context(session.connect_span if session else None): + _record(lambda: headers(manager, session)) + connection = await function(manager) + if session: + connection._voice_trace = session # pylint: disable=protected-access + _record(session.opened) + return connection + except BaseException as error: + if session: + _record(lambda: session.close(clean=False, error=error)) + raise + finally: + manager._extra_headers = original_headers # pylint: disable=protected-access + + return async_wrapper + + @functools.wraps(function) + def wrapper(manager: Any) -> Any: + session = _record(lambda: prepare(manager)) + original_headers = manager._extra_headers # pylint: disable=protected-access + try: + with _context(session.connect_span if session else None): + _record(lambda: headers(manager, session)) + connection = function(manager) + if session: + connection._voice_trace = session # pylint: disable=protected-access + _record(session.opened) + return connection + except BaseException as error: + if session: + _record(lambda: session.close(clean=False, error=error)) + raise + finally: + manager._extra_headers = original_headers # pylint: disable=protected-access + + return wrapper + + def _connection(self, function: Callable, method: str) -> Callable: + def snapshot(connection: Any, args: tuple, kwargs: dict) -> Optional[dict[str, Any]]: + session = getattr(connection, "_voice_trace", None) + if method == "send" and session and not session.ended and self.enabled(): + return session.snapshot_sent(args[0] if args else kwargs.get("event")) + return None + + def observe(connection: Any, kwargs: dict, result: Any, error: Optional[BaseException]) -> None: + session = getattr(connection, "_voice_trace", None) + if session is None or session.ended: + return + if method == "close": + received_code = getattr(connection._connection, "close_code", None) # pylint: disable=protected-access + code = received_code if isinstance(received_code, int) else kwargs.get("code", 1000) + clean = error is None and code in (1000, 1001) + termination = error + if termination is None and not clean: + # aiohttp assigns 1006 locally when a receive wait is cancelled. + if session.receive_cancelled and code == 1006 and kwargs.get("code", 1000) in (1000, 1001): + termination = CancelledError() + else: + termination = ConnectionResetError() + session.close(code, clean, termination) + elif error: + if method == "recv" and isinstance(error, CancelledError): + session.receive_cancelled = True + _event(session.span, "voice_agents.receive.cancelled") + elif method == "recv" and isinstance(error, ConnectionResetError): + code = getattr(connection._connection, "close_code", None) # pylint: disable=protected-access + clean = error.__cause__ is None and code in (1000, 1001) + session.close(code, clean, None if clean else error) + else: + _event( + session.span, + "voice_agents." + ("send" if method == "send" else "receive") + ".failed", + _error_attributes(error), + ) + elif self.enabled(): + if method == "send": + if result is not None: + session.sent(result) + else: + session.receive_cancelled = False + session.received(result) + + if inspect.iscoroutinefunction(function): + + @functools.wraps(function) + async def async_wrapper(connection: Any, *args: Any, **kwargs: Any) -> Any: + sent = _record(lambda: snapshot(connection, args, kwargs)) if method == "send" else None + try: + result = await function(connection, *args, **kwargs) + except BaseException as error: + _record(lambda: observe(connection, kwargs, None, error)) + raise + _record(lambda: observe(connection, kwargs, sent if method == "send" else result, None)) + return result + + return async_wrapper + + @functools.wraps(function) + def wrapper(connection: Any, *args: Any, **kwargs: Any) -> Any: + sent = _record(lambda: snapshot(connection, args, kwargs)) if method == "send" else None + try: + result = function(connection, *args, **kwargs) + except BaseException as error: + _record(lambda: observe(connection, kwargs, None, error)) + raise + _record(lambda: observe(connection, kwargs, sent if method == "send" else result, None)) + return result + + return wrapper + + def _exit(self, function: Callable) -> Callable: + def mark(instance: Any, details: tuple) -> None: + connection = getattr(instance, "_connection", None) + session = getattr(instance, "_voice_trace", None) or getattr(connection, "_voice_trace", None) + if session and len(details) > 1 and isinstance(details[1], BaseException): + session.fail(details[1]) + + if inspect.iscoroutinefunction(function): + + @functools.wraps(function) + async def async_wrapper(instance: Any, *details: Any) -> Any: + _record(lambda: mark(instance, details)) + return await function(instance, *details) + + return async_wrapper + + @functools.wraps(function) + def wrapper(instance: Any, *details: Any) -> Any: + _record(lambda: mark(instance, details)) + return function(instance, *details) + + return wrapper + + def instrument(self) -> None: + if _ORIGINALS: + return + if _responses_instrumentor._operation_duration_histogram is None: # pylint: disable=protected-access + self.metrics._initialize_metrics() # pylint: disable=protected-access + mutations = ( + "create_call_job", + "cancel_call_job", + "transfer_call", + "end_call", + "create_binding", + "update_binding", + "delete_binding", + "replace_transfer_targets", + ) + for module in ("azure.ai.projects.operations", "azure.ai.projects.aio.operations"): + operations = importlib.import_module(module) + targets = [ + ("AgentsOperations", "create_version", "create_agent", True), + ("BetaAgentsOperations", "create_from_prompt", "create_agent", False), + ("BetaVoiceAgentsConversationsOperations", "delete", "voice_agents.conversations.delete", False), + ] + [ + ("BetaVoiceAgentsTelephonyOperations", method, "voice_agents.telephony." + method, False) + for method in mutations + ] + for class_name, method, operation, voice_only in targets: + cls = getattr(operations, class_name) + original = getattr(cls, method) + _ORIGINALS[(cls, method)] = original + setattr(cls, method, self._rest(original, operation, voice_only)) + for module, prefix in (("azure.ai.projects._realtime", ""), ("azure.ai.projects.aio._realtime", "Async")): + realtime = importlib.import_module(module) + manager = getattr(realtime, prefix + "BetaRealtimeConnectionManager") + original = manager.enter + _ORIGINALS[(manager, "enter")] = original + manager.enter = self._enter(original) + connection = getattr(realtime, prefix + "BetaRealtimeConnection") + exit_method = "__aexit__" if prefix else "__exit__" + for cls in (manager, connection): + original = getattr(cls, exit_method) + _ORIGINALS[(cls, exit_method)] = original + setattr(cls, exit_method, self._exit(original)) + for method in ("send", "recv", "close"): + original = getattr(connection, method) + _ORIGINALS[(connection, method)] = original + setattr(connection, method, self._connection(original, method)) + + def uninstrument(self) -> None: + for session in tuple(_SESSIONS): + _record(session.close) + for (cls, method), original in _ORIGINALS.items(): + setattr(cls, method, original) + _ORIGINALS.clear() diff --git a/sdk/ai/azure-ai-projects/dev_requirements.txt b/sdk/ai/azure-ai-projects/dev_requirements.txt index a8928e8a7c9b..c52ca481a03c 100644 --- a/sdk/ai/azure-ai-projects/dev_requirements.txt +++ b/sdk/ai/azure-ai-projects/dev_requirements.txt @@ -1,8 +1,10 @@ -e ../../../eng/tools/azure-sdk-tools ../../core/azure-core ../../identity/azure-identity -# pinning remote version due to limitations of azdo feeds with this package -https://azuresdkartifacts.z5.web.core.windows.net/python/distributions/distros/opentelemetry_resource_detector_azure-0.1.5-py3-none-any.whl +# Pinned by name: the Azure SDK feed now serves the wheel that used to be installed from a direct URL. +# Do not use a direct URL here: pip re-downloads it on every `pip install -r` (including the ones azpysdk +# runs), which fails wherever that host is unreachable, e.g. the Copilot cloud agent firewall. +opentelemetry-resource-detector-azure==0.1.5 aiohttp azure-core-tracing-opentelemetry azure-mgmt-authorization diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling.py index 36467b8d24d1..019a79ecde08 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling.py @@ -24,13 +24,11 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. The name of the agent to optimize. - 3) DATASET_NAME - Required. The name of the registered training dataset. - 4) EVALUATOR_NAME - Required. The name of a registered project evaluator. - 5) DATASET_VERSION - Optional. Version of the training dataset. Defaults to "1". - 6) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. - 7) EVAL_MODEL - Required. The evaluation model deployment in + 3) EVALUATOR_NAME - Required. The name of a registered project evaluator. + 4) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. + 5) EVAL_MODEL - Required. The evaluation model deployment in "{connectionName}/{deploymentName}" format. - 8) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". + 6) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". """ import os @@ -49,8 +47,9 @@ AgentOptimizationJob, AgentOptimizationModelConfiguration, AgentOptimizationSpace, - AgentOptimizationTargetCompletionDatasetReferenceDataSource, AgentOptimizationTargetCompletionEvaluationSet, + AgentOptimizationTargetCompletionInlineDataSource, + AgentOptimizationTargetCompletionTestCase, EvaluationModelConfiguration, JobStatus, ) @@ -59,9 +58,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -dataset_name = os.environ["DATASET_NAME"] evaluator_name = os.environ["EVALUATOR_NAME"] -dataset_version = os.environ.get("DATASET_VERSION", "1") poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) eval_model = os.environ["EVAL_MODEL"] optimization_model = os.environ.get("OPTIMIZATION_MODEL", "gpt-5.1") @@ -84,9 +81,21 @@ optimization_configuration=AgentOptimizationConfiguration( evaluation_configuration=AgentOptimizationEvaluationConfiguration( training_set=AgentOptimizationTargetCompletionEvaluationSet( - source=AgentOptimizationTargetCompletionDatasetReferenceDataSource( - name=dataset_name, - version=dataset_version, + source=AgentOptimizationTargetCompletionInlineDataSource( + test_cases=[ + AgentOptimizationTargetCompletionTestCase( + query="What is the capital of France?", + ground_truth="Paris", + ), + AgentOptimizationTargetCompletionTestCase( + query="What is 2 + 2?", + ground_truth="4", + ), + AgentOptimizationTargetCompletionTestCase( + query="Name the largest ocean on Earth.", + ground_truth="Pacific Ocean", + ), + ], ) ), evaluators=[AgentOptimizationEvaluator(name=evaluator_name)], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling_async.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling_async.py index 523de0cb904d..86b0eaa025e1 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_advanced_app_polling_async.py @@ -24,13 +24,11 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. The name of the agent to optimize. - 3) DATASET_NAME - Required. The name of the registered training dataset. - 4) EVALUATOR_NAME - Required. The name of a registered project evaluator. - 5) DATASET_VERSION - Optional. Version of the training dataset. Defaults to "1". - 6) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. - 7) EVAL_MODEL - Required. The evaluation model deployment in + 3) EVALUATOR_NAME - Required. The name of a registered project evaluator. + 4) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. + 5) EVAL_MODEL - Required. The evaluation model deployment in "{connectionName}/{deploymentName}" format. - 8) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". + 6) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". """ import asyncio @@ -49,8 +47,9 @@ AgentOptimizationJob, AgentOptimizationModelConfiguration, AgentOptimizationSpace, - AgentOptimizationTargetCompletionDatasetReferenceDataSource, AgentOptimizationTargetCompletionEvaluationSet, + AgentOptimizationTargetCompletionInlineDataSource, + AgentOptimizationTargetCompletionTestCase, EvaluationModelConfiguration, JobStatus, ) @@ -59,9 +58,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -dataset_name = os.environ["DATASET_NAME"] evaluator_name = os.environ["EVALUATOR_NAME"] -dataset_version = os.environ.get("DATASET_VERSION", "1") poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) eval_model = os.environ["EVAL_MODEL"] optimization_model = os.environ.get("OPTIMIZATION_MODEL", "gpt-5.1") @@ -86,9 +83,21 @@ async def main() -> None: optimization_configuration=AgentOptimizationConfiguration( evaluation_configuration=AgentOptimizationEvaluationConfiguration( training_set=AgentOptimizationTargetCompletionEvaluationSet( - source=AgentOptimizationTargetCompletionDatasetReferenceDataSource( - name=dataset_name, - version=dataset_version, + source=AgentOptimizationTargetCompletionInlineDataSource( + test_cases=[ + AgentOptimizationTargetCompletionTestCase( + query="What is the capital of France?", + ground_truth="Paris", + ), + AgentOptimizationTargetCompletionTestCase( + query="What is 2 + 2?", + ground_truth="4", + ), + AgentOptimizationTargetCompletionTestCase( + query="Name the largest ocean on Earth.", + ground_truth="Pacific Ocean", + ), + ], ) ), evaluators=[AgentOptimizationEvaluator(name=evaluator_name)], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py index e83df2ae373b..d427fbb66fc6 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py @@ -25,13 +25,11 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. The name of the agent to optimize. - 3) DATASET_NAME - Required. The name of the registered training dataset. - 4) EVALUATOR_NAME - Required. The name of a registered project evaluator. - 5) DATASET_VERSION - Optional. Version of the training dataset. Defaults to "1". - 6) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. - 7) EVAL_MODEL - Required. The evaluation model deployment in + 3) EVALUATOR_NAME - Required. The name of a registered project evaluator. + 4) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. + 5) EVAL_MODEL - Required. The evaluation model deployment in "{connectionName}/{deploymentName}" format. - 8) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". + 6) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". """ import os @@ -50,8 +48,9 @@ AgentOptimizationJob, AgentOptimizationModelConfiguration, AgentOptimizationSpace, - AgentOptimizationTargetCompletionDatasetReferenceDataSource, AgentOptimizationTargetCompletionEvaluationSet, + AgentOptimizationTargetCompletionInlineDataSource, + AgentOptimizationTargetCompletionTestCase, EvaluationModelConfiguration, ) @@ -59,9 +58,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -dataset_name = os.environ["DATASET_NAME"] evaluator_name = os.environ["EVALUATOR_NAME"] -dataset_version = os.environ.get("DATASET_VERSION", "1") poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) eval_model = os.environ["EVAL_MODEL"] optimization_model = os.environ.get("OPTIMIZATION_MODEL", "gpt-5.1") @@ -80,9 +77,21 @@ optimization_configuration=AgentOptimizationConfiguration( evaluation_configuration=AgentOptimizationEvaluationConfiguration( training_set=AgentOptimizationTargetCompletionEvaluationSet( - source=AgentOptimizationTargetCompletionDatasetReferenceDataSource( - name=dataset_name, - version=dataset_version, + source=AgentOptimizationTargetCompletionInlineDataSource( + test_cases=[ + AgentOptimizationTargetCompletionTestCase( + query="What is the capital of France?", + ground_truth="Paris", + ), + AgentOptimizationTargetCompletionTestCase( + query="What is 2 + 2?", + ground_truth="4", + ), + AgentOptimizationTargetCompletionTestCase( + query="Name the largest ocean on Earth.", + ground_truth="Pacific Ocean", + ), + ], ) ), evaluators=[AgentOptimizationEvaluator(name=evaluator_name)], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py index 39a23da0caf6..f7a3115f3195 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py @@ -25,13 +25,11 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. The name of the agent to optimize. - 3) DATASET_NAME - Required. The name of the registered training dataset. - 4) EVALUATOR_NAME - Required. The name of a registered project evaluator. - 5) DATASET_VERSION - Optional. Version of the training dataset. Defaults to "1". - 6) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. - 7) EVAL_MODEL - Required. The evaluation model deployment in + 3) EVALUATOR_NAME - Required. The name of a registered project evaluator. + 4) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. + 5) EVAL_MODEL - Required. The evaluation model deployment in "{connectionName}/{deploymentName}" format. - 8) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". + 6) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". """ import asyncio @@ -50,8 +48,9 @@ AgentOptimizationJob, AgentOptimizationModelConfiguration, AgentOptimizationSpace, - AgentOptimizationTargetCompletionDatasetReferenceDataSource, AgentOptimizationTargetCompletionEvaluationSet, + AgentOptimizationTargetCompletionInlineDataSource, + AgentOptimizationTargetCompletionTestCase, EvaluationModelConfiguration, ) @@ -59,9 +58,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -dataset_name = os.environ["DATASET_NAME"] evaluator_name = os.environ["EVALUATOR_NAME"] -dataset_version = os.environ.get("DATASET_VERSION", "1") poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) eval_model = os.environ["EVAL_MODEL"] optimization_model = os.environ.get("OPTIMIZATION_MODEL", "gpt-5.1") @@ -82,9 +79,21 @@ async def main() -> None: optimization_configuration=AgentOptimizationConfiguration( evaluation_configuration=AgentOptimizationEvaluationConfiguration( training_set=AgentOptimizationTargetCompletionEvaluationSet( - source=AgentOptimizationTargetCompletionDatasetReferenceDataSource( - name=dataset_name, - version=dataset_version, + source=AgentOptimizationTargetCompletionInlineDataSource( + test_cases=[ + AgentOptimizationTargetCompletionTestCase( + query="What is the capital of France?", + ground_truth="Paris", + ), + AgentOptimizationTargetCompletionTestCase( + query="What is 2 + 2?", + ground_truth="4", + ), + AgentOptimizationTargetCompletionTestCase( + query="Name the largest ocean on Earth.", + ground_truth="Pacific Ocean", + ), + ], ) ), evaluators=[AgentOptimizationEvaluator(name=evaluator_name)], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py index 58725501ff68..a393e318d201 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py @@ -20,13 +20,11 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. The name of the agent to optimize. - 3) DATASET_NAME - Required. The name of the registered training dataset. - 4) EVALUATOR_NAME - Required. The name of a registered project evaluator. - 5) DATASET_VERSION - Optional. Version of the training dataset. Defaults to "1". - 6) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. - 7) EVAL_MODEL - Required. The evaluation model deployment in + 3) EVALUATOR_NAME - Required. The name of a registered project evaluator. + 4) POLL_INTERVAL_SECONDS - Optional. Seconds between status polls. Defaults to 10. + 5) EVAL_MODEL - Required. The evaluation model deployment in "{connectionName}/{deploymentName}" format. - 8) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". + 6) OPTIMIZATION_MODEL - Optional. The model used for optimization. Defaults to "gpt-5.1". """ @@ -46,8 +44,9 @@ AgentOptimizationJob, AgentOptimizationModelConfiguration, AgentOptimizationSpace, - AgentOptimizationTargetCompletionDatasetReferenceDataSource, AgentOptimizationTargetCompletionEvaluationSet, + AgentOptimizationTargetCompletionInlineDataSource, + AgentOptimizationTargetCompletionTestCase, EvaluationModelConfiguration, ) @@ -55,9 +54,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -dataset_name = os.environ["DATASET_NAME"] evaluator_name = os.environ["EVALUATOR_NAME"] -dataset_version = os.environ.get("DATASET_VERSION", "1") poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) eval_model = os.environ["EVAL_MODEL"] optimization_model = os.environ.get("OPTIMIZATION_MODEL", "gpt-5.1") @@ -77,9 +74,21 @@ optimization_configuration=AgentOptimizationConfiguration( evaluation_configuration=AgentOptimizationEvaluationConfiguration( training_set=AgentOptimizationTargetCompletionEvaluationSet( - source=AgentOptimizationTargetCompletionDatasetReferenceDataSource( - name=dataset_name, - version=dataset_version, + source=AgentOptimizationTargetCompletionInlineDataSource( + test_cases=[ + AgentOptimizationTargetCompletionTestCase( + query="What is the capital of France?", + ground_truth="Paris", + ), + AgentOptimizationTargetCompletionTestCase( + query="What is 2 + 2?", + ground_truth="4", + ), + AgentOptimizationTargetCompletionTestCase( + query="Name the largest ocean on Earth.", + ground_truth="Pacific Ocean", + ), + ], ) ), evaluators=[AgentOptimizationEvaluator(name=evaluator_name)], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py index fffbe15e7f04..79789e1cc37f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py @@ -20,7 +20,7 @@ 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found in the overview page of your Microsoft Foundry portal. 2) FOUNDRY_AGENT_NAME - Required. Filter the list to jobs for this agent. - 3) JOB_ID - Required. If set, fetches and deletes this specific job. + 3) JOB_ID - Optional. If set, fetches and deletes this specific job. """ import os @@ -35,7 +35,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] agent_name = os.environ["FOUNDRY_AGENT_NAME"] -job_id = os.environ["JOB_ID"] +job_id = os.environ.get("JOB_ID") with ( DefaultAzureCredential() as credential, diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_browser_automation.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_browser_automation.py index 316933cfaaa8..59090cf05558 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_browser_automation.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_browser_automation.py @@ -8,6 +8,8 @@ This sample demonstrates how to create an AI agent with Browser Automation capabilities using the BrowserAutomationTool and synchronous Azure AI Projects client. The agent can perform automated web browsing tasks and provide responses based on web interactions. + The agent shares a live-view URL at the start of the run, so you can watch the browser + session. Treat that URL as sensitive and do not share it. USAGE: python sample_agent_browser_automation.py @@ -71,34 +73,24 @@ ): stream_response = openai_client.responses.create( stream=True, - tool_choice="required", input=""" - Your goal is to report the percent of Microsoft year-to-date stock price change. - To do that, go to the website finance.yahoo.com. - At the top of the page, you will find a search bar. - Enter the value 'MSFT', to get information about the Microsoft stock price. - At the top of the resulting page you will see a default chart of Microsoft stock price. - Click on 'YTD' at the top of that chart, and report the percent value that shows up just below it.""", + Use the Browser Automation tool to visit https://example.com. + Read the page and report its title and the visible description paragraph. + Base your answer on the browser results, not on prior knowledge.""", ) for event in stream_response: if event.type == "response.created": - print(f"Follow-up response created with ID: {event.response.id}") - elif event.type == "response.output_text.delta": - print(f"Delta: {event.delta}") - elif event.type == "response.text.done": - print("\nFollow-up response done!") + print(f"Response created with ID: {event.response.id}") elif event.type == "response.output_item.done": item = event.item - if item.type == "browser_automation_call": - arguments_str = getattr(item, "arguments", "{}") - - # Parse the arguments string into a dictionary - arguments = json.loads(arguments_str) - query = arguments.get("query") - - print(f"Call ID: {getattr(item, 'call_id')}") - print(f"Query arguments: {query}") + # Each browser action (navigate, click, type, ...) is surfaced as a "remote_function_call" item. + if item.type == "remote_function_call": + arguments = json.loads(getattr(item, "arguments", None) or "{}") + arguments.pop("browserSessionId", None) + print(f"Browser action: {item.name} (call ID: {item.call_id}), arguments: {arguments}") + elif event.type == "response.output_text.done": + # Includes the live view URL message as well as the final answer. + print(f"\nAgent message: {event.text}") elif event.type == "response.completed": - print("\nFollow-up completed!") - print(f"Full response: {event.response.output_text}") \ No newline at end of file + print("\nResponse completed!") diff --git a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_read_conversation.py b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_read_conversation.py index a5b027622639..acbd0aca3c96 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_read_conversation.py +++ b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_read_conversation.py @@ -110,15 +110,19 @@ print("Responses:") for response in conversations.list_responses(agent_name, conversation_id): print(f" - {response.id}: status={response.status}") + response_id = response.id + if not response_id: + print(" No response ID available; skipping response details.") + continue # Read a single response back, with its output and token usage. - detail = conversations.get_response(agent_name, conversation_id, response.id) + detail = conversations.get_response(agent_name, conversation_id, response_id) print(f" usage={detail.usage}") # The items produced by this specific response. Conversation items # belong to an open union, so on read they surface as mappings # keyed by their wire fields (``type``, ``id``, ...). - for response_item in conversations.list_response_items(agent_name, conversation_id, response.id): + for response_item in conversations.list_response_items(agent_name, conversation_id, response_id): print(f" item {response_item.get('type')} id={response_item.get('id')}") # The ordered conversation items -- the full transcript (user + assistant + tool events). @@ -134,8 +138,8 @@ # Deleting a conversation removes it and all of its responses, items, and audio. # This is destructive, so it is shown but not run by default. Uncomment to enable. - # deleted = conversations.delete(agent_name, conversation_id) - # print(f"Deleted conversation {deleted.id}: deleted={deleted.deleted}") + # conversations.delete(agent_name, conversation_id) + # print(f"Deleted conversation {conversation_id}") except HttpResponseError as e: # 404 typically means the conversation was not persisted (agent ran with `store=False`). print(f"Service responded with an error: {e.status_code} {e.reason}") diff --git a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_realtime_audio_conversation_async.py b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_realtime_audio_conversation_async.py index 6b4de4f23bd3..e974e8dbb70f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_realtime_audio_conversation_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_realtime_audio_conversation_async.py @@ -62,6 +62,7 @@ from azure.ai.projects.aio.operations import AsyncBetaRealtimeConnection # pylint: disable=no-name-in-module from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( + RealtimeAudioFormatsAudioPcm, RealtimeServerEventConversationItemInputAudioTranscriptionCompleted, RealtimeServerEventInputAudioBufferSpeechStarted, RealtimeServerEventResponseAudioDelta, @@ -71,8 +72,11 @@ RealtimeServerEventSessionCreated, RealtimeServerEventError, VoiceAgentAudioConfig, + VoiceAgentAudioInputConfig, VoiceAgentAudioOutputConfig, VoiceAgentDefinition, + VoiceAgentInputTranscription, + VoiceAgentInputTranscriptionModel, VoiceModelType, VoiceOutputModality, VoiceType, @@ -149,8 +153,7 @@ def __init__(self, connection: "AsyncBetaRealtimeConnection") -> None: # Bounds capture backpressure to a single in-flight send (see start_capture). self._pending_send: "Optional[concurrent.futures.Future[None]]" = None self._dropped_frames = 0 - # Only counts bytes actually handed to input_audio_buffer.append() -- frames dropped - # above due to backpressure are never sent, so they must not be counted here. + # Count only successfully sent payloads, not captured or queued frames. self._input_bytes = 0 self._input_stream = None @@ -175,10 +178,7 @@ def _capture_callback(in_data, _frame_count, _time_info, _status): if self._pending_send is not None and not self._pending_send.done(): self._dropped_frames += 1 return (None, pyaudio.paContinue) - self._input_bytes += len(in_data) - self._pending_send = asyncio.run_coroutine_threadsafe( - self._conn.input_audio_buffer.append(audio=in_data), self._loop - ) + self._pending_send = asyncio.run_coroutine_threadsafe(self._append_input_audio(in_data), self._loop) return (None, pyaudio.paContinue) self._input_stream = self._audio.open( @@ -190,6 +190,26 @@ def _capture_callback(in_data, _frame_count, _time_info, _status): stream_callback=_capture_callback, ) + async def _append_input_audio(self, pcm: bytes) -> None: + """Send a PCM payload and count it only after the send succeeds. + + :param bytes pcm: The captured PCM16 audio payload. + """ + await self._conn.input_audio_buffer.append(audio=pcm) + self._input_bytes += len(pcm) + + async def stop_capture(self) -> None: + """Stop the microphone and finish the last queued send before reporting its size.""" + if self._input_stream is not None: + self._input_stream.stop_stream() + self._input_stream.close() + self._input_stream = None + if self._pending_send is not None: + await asyncio.wrap_future(self._pending_send) + self._pending_send = None + if self._dropped_frames: + print(f"(dropped {self._dropped_frames} mic frame(s) while a send was still in flight)") + # -- playback ------------------------------------------------------------ def start_playback(self) -> None: @@ -371,19 +391,22 @@ async def _run_audio_conversation(client: AIProjectClient, agent_name: str) -> O # Ctrl-C ends the session; read back whatever was persisted so far. print("\n(ending session...)") finally: - input_bytes = ap.input_bytes_sent - output_bytes = ap.output_bytes_received - print(f"(received {ap.seconds:.2f}s of reply audio this session)") - print( - f"Input audio (mic -> service): format=PCM16, sample_rate={_SAMPLE_RATE} Hz, channels=1, " - f"duration={ap.input_seconds:.2f}s, size={_format_size(input_bytes)}" - ) - print( - f"Output audio (service -> speakers): format=PCM16, sample_rate={_SAMPLE_RATE} Hz, channels=1, " - f"duration={ap.seconds:.2f}s, size={_format_size(output_bytes)}" - ) - print(f"Total audio transferred: size={_format_size(input_bytes + output_bytes)}") - ap.shutdown() + try: + await ap.stop_capture() + finally: + input_bytes = ap.input_bytes_sent + output_bytes = ap.output_bytes_received + print(f"(received {ap.seconds:.2f}s of reply audio this session)") + print( + f"Input audio (mic -> service): format=PCM16, sample_rate={_SAMPLE_RATE} Hz, channels=1, " + f"duration={ap.input_seconds:.2f}s, size={_format_size(input_bytes)}" + ) + print( + f"Output audio (service -> speakers): format=PCM16, sample_rate={_SAMPLE_RATE} Hz, channels=1, " + f"duration={ap.seconds:.2f}s, size={_format_size(output_bytes)}" + ) + print(f"Total audio transferred: size={_format_size(input_bytes + output_bytes)}") + ap.shutdown() return conversation_id @@ -432,6 +455,10 @@ async def audio_conversation() -> None: model=model, instructions="You are a friendly voice assistant. Keep replies short and natural.", audio=VoiceAgentAudioConfig( + input=VoiceAgentAudioInputConfig( + format=RealtimeAudioFormatsAudioPcm(rate=_SAMPLE_RATE), + transcription=VoiceAgentInputTranscription(model=VoiceAgentInputTranscriptionModel.WHISPER1), + ), output=VoiceAgentAudioOutputConfig(voice="en-US-AvaNeural", voice_type=VoiceType.AZURE_STANDARD), ), output_modalities=[VoiceOutputModality.AUDIO], diff --git a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_with_tools.py b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_with_tools.py index fbae112a17c4..8b12a235abbb 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_with_tools.py +++ b/sdk/ai/azure-ai-projects/samples/agents/voice/sample_voice_agent_with_tools.py @@ -91,7 +91,7 @@ # An MCP tool is executed by the service against a remote MCP server you own. # It references an external server, so it is constructed here for illustration -# and not attached below. Provide one of server_url, connector_id, or tunnel_id. +# and not attached below. Provide server_url; connector and tunnel IDs are not supported. _example_mcp_tool = VoiceAgentMcpTool( server_label="my-mcp-server", server_url="https://example.com/mcp", diff --git a/sdk/ai/azure-ai-projects/samples/datasets/sample_dataset_generation_job_simulation_seed_for_evaluation.py b/sdk/ai/azure-ai-projects/samples/datasets/sample_dataset_generation_job_simulation_seed_for_evaluation.py index 464a325f8af3..e22466c1eee7 100644 --- a/sdk/ai/azure-ai-projects/samples/datasets/sample_dataset_generation_job_simulation_seed_for_evaluation.py +++ b/sdk/ai/azure-ai-projects/samples/datasets/sample_dataset_generation_job_simulation_seed_for_evaluation.py @@ -22,15 +22,14 @@ `SimulationSeedDataGenerationJobConfiguration` can be used with prompt, file, or agent sources. The service requires `max_samples` (1-1000) for this generation - type; because the options class does not expose it as a keyword argument, the - sample sets it through the model's mapping interface. + type, which is passed as a constructor argument. USAGE: python sample_dataset_generation_job_simulation_seed_for_evaluation.py Before running the sample: - pip install "azure-ai-projects>=2.8.0" azure-identity python-dotenv + pip install "azure-ai-projects>=2.10.0" azure-identity python-dotenv Set these environment variables with your own values: 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as found @@ -133,12 +132,9 @@ def main() -> None: # 1. Submit a simulation seed data generation job. # ------------------------------------------------------------------ generation_configuration = SimulationSeedDataGenerationJobConfiguration( + max_samples=15, model_options=DataGenerationModelOptions(model=model_name), ) - # The service currently requires `max_samples` (1-1000) for `simulation_seed` - # jobs, but `SimulationSeedDataGenerationJobConfiguration` does not expose it as a - # keyword argument, so set it through the model's mapping interface. - generation_configuration["max_samples"] = 15 job = EvaluationDataGenerationJobInputs( name=f"simulation-seed-{run_id}", diff --git a/sdk/ai/azure-ai-projects/samples/evaluations/sample_rubric_evaluator_generation_lifecycle.py b/sdk/ai/azure-ai-projects/samples/evaluations/sample_rubric_evaluator_generation_lifecycle.py index b0e98b44436c..e437b80c426e 100644 --- a/sdk/ai/azure-ai-projects/samples/evaluations/sample_rubric_evaluator_generation_lifecycle.py +++ b/sdk/ai/azure-ai-projects/samples/evaluations/sample_rubric_evaluator_generation_lifecycle.py @@ -114,20 +114,14 @@ ) # Retrieve the persisted generation job using the id returned in the LRO result. - assert ( - evaluator.generation_job_id is not None - ), "Expected the generated evaluator to include a generation job id." - replay_job = project_client.evaluators.get_generation_job( - evaluator.generation_job_id - ) + assert evaluator.generation_job_id is not None, "Expected the generated evaluator to include a generation job id." + replay_job = project_client.evaluators.get_generation_job(evaluator.generation_job_id) assert replay_job.id == evaluator.generation_job_id # 2. List the 5 most recent generation jobs in this project. # `limit` controls the page size; use `itertools.islice` to cap the total. print("Recent generation jobs:") - for entry in itertools.islice( - project_client.evaluators.list_generation_jobs(limit=5, order=PageOrder.DESC), 5 - ): + for entry in itertools.islice(project_client.evaluators.list_generation_jobs(limit=5, order=PageOrder.DESC), 5): print( f" - id=`{entry.id}` status=`{cast(JobStatus, entry.status).value}` " f"evaluator_name=`{entry.evaluator_name}`" @@ -139,9 +133,7 @@ # 4. Clean up. `delete_version` cascades to the generation job record, so # the explicit delete below may return 404. print("Cleaning up.") - project_client.evaluators.delete_version( - name=evaluator.name, version=evaluator.version - ) + project_client.evaluators.delete_version(name=evaluator.name, version=evaluator.version) try: project_client.evaluators.delete_generation_job(evaluator.generation_job_id) except ResourceNotFoundError: diff --git a/sdk/ai/azure-ai-projects/samples/logs/sample_log_from_voice_agent.py b/sdk/ai/azure-ai-projects/samples/logs/sample_log_from_voice_agent.py new file mode 100644 index 000000000000..698d689f9574 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/logs/sample_log_from_voice_agent.py @@ -0,0 +1,137 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + Capture Voice Agent WebSocket diagnostics from azure.ai.projects.realtime + into a timestamped temporary log file. The SDK logs connection lifecycle, + event types and serialized event sizes, not event payloads or credentials. + No OpenTelemetry configuration or GenAI tracing opt-in is required. + + Create a preview Voice Agent, send one text prompt, wait for its response, + and delete the created version. No microphone or speaker is required. + +USAGE: + python samples/logs/sample_log_from_voice_agent.py + + Before running the sample: + + pip install "azure-ai-projects[voice]" azure-identity python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint. + 2) FOUNDRY_VOICE_AGENT_MODEL - Optional managed model; defaults to "gpt-realtime". + 3) FOUNDRY_VOICE_AGENT_NAME - Optional agent name; defaults to a unique sample name. + + Authenticates with DefaultAzureCredential. The sample prints the log file path + and restores the logger's configuration when it finishes. +""" + +import logging +import os +import time +import uuid + +from dotenv import load_dotenv +from log_utils import create_timestamped_temp_log_file +from azure.identity import DefaultAzureCredential +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + RealtimeConversationItemMessageUser, + RealtimeConversationItemMessageUserContent, + RealtimeConversationItemType, + RealtimeServerEventError, + RealtimeServerEventResponseDone, + VoiceAgentAudioConfig, + VoiceAgentAudioOutputConfig, + VoiceAgentDefinition, + VoiceModelType, + VoiceOutputModality, + VoiceType, +) + +load_dotenv() + + +def _run_conversation(project_client: AIProjectClient, agent_name: str) -> None: + """Send one text prompt and wait for completion. + + :param project_client: The Foundry project client. + :type project_client: ~azure.ai.projects.AIProjectClient + :param agent_name: The sample's voice agent name. + :type agent_name: str + """ + with project_client.beta.voice_agents.realtime.connect(agent_name=agent_name) as connection: + connection.conversation.item.create( + item=RealtimeConversationItemMessageUser( + type=RealtimeConversationItemType.MESSAGE, + content=[RealtimeConversationItemMessageUserContent(type="input_text", text="What is two plus three?")], + ) + ) + connection.response.create() + deadline = time.monotonic() + 60 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("The voice response did not finish within 60 seconds.") + event = connection.recv(timeout=remaining) + if isinstance(event, RealtimeServerEventError): + raise RuntimeError("The realtime service returned an error. Inspect its error event.") + if isinstance(event, RealtimeServerEventResponseDone): + if event.response.status != "completed": + raise RuntimeError("The voice response did not complete successfully.") + break + + +def main() -> None: + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ.get("FOUNDRY_VOICE_AGENT_MODEL") or "gpt-realtime" + agent_name = os.environ.get("FOUNDRY_VOICE_AGENT_NAME") or f"sample-voice-agent-logs-{uuid.uuid4().hex[:8]}" + log_file = create_timestamped_temp_log_file(__file__) + file_handler = logging.FileHandler(filename=log_file, encoding="utf-8") + logger = logging.getLogger("azure.ai.projects.realtime") + previous_level = logger.level + previous_propagate = logger.propagate + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(file_handler) + + try: + with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + ): + created_version = None + try: + created_version = project_client.agents.create_version( + agent_name=agent_name, + definition=VoiceAgentDefinition( + model_type=VoiceModelType.MANAGED, + model=model, + instructions="You are a helpful assistant. Keep replies short.", + audio=VoiceAgentAudioConfig( + output=VoiceAgentAudioOutputConfig( + voice="en-US-AvaNeural", voice_type=VoiceType.AZURE_STANDARD + ) + ), + output_modalities=[VoiceOutputModality.TEXT], + store=False, + ), + ) + _run_conversation(project_client, agent_name) + finally: + if created_version is not None: + project_client.agents.delete_version(agent_name=agent_name, agent_version=created_version.version) + finally: + logger.removeHandler(file_handler) + file_handler.close() + logger.setLevel(previous_level) + logger.propagate = previous_propagate + + print(f"Voice Agent realtime logs written to {log_file}") + + +if __name__ == "__main__": + main() diff --git a/sdk/ai/azure-ai-projects/tests.yml b/sdk/ai/azure-ai-projects/tests.yml new file mode 100644 index 000000000000..512c00fc1f6a --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests.yml @@ -0,0 +1,307 @@ +trigger: none +pr: none + +parameters: + - name: selectionKind + displayName: 'Query type (test-id example: *test_agent_tools_samples*[*mcp*project_connection*])' + type: string + default: sample-path + values: + - sample-path + - test-id + - name: selection + displayName: 'Pattern (path example: samples/agents/tools/*project_connection*.py; use ; for OR)' + type: string + default: 'samples/agents/tools/sample_agent_mcp_with_project_connection.py;samples/agents/tools/sample_agent_mcp_with_project_connection_async.py' + - name: testQueueCount + displayName: 'Maximum parallel billable RG workers (selection is divided round-robin, not repeated)' + type: number + default: 4 + values: + - 1 + - 2 + - 3 + - 4 + - name: identityResourceGroup + displayName: 'Resource group with the shared identities (e.g. rg-azsdk-identities-westus3-102826_164519)' + type: string + - name: modelName + displayName: 'Sample model (validation uses the existing project from an environment variable)' + type: string + default: gpt-5.2 + - name: modelVersion + displayName: 'Sample model version; use auto to resolve the regional default' + type: string + default: '2025-12-11' + - name: modelSku + displayName: 'Sample model deployment SKU' + type: string + default: GlobalStandard + values: + - GlobalStandard + - Standard + - name: modelCapacity + displayName: 'Sample model capacity units (quota checked before provisioning)' + type: number + default: 10 + +variables: + packageRoot: $(Build.SourcesDirectory)/sdk/ai/azure-ai-projects + # Connection configuration belongs here, not in the Run pipeline form. + azureServiceConnection: azure-sdk-tests + PYTHON_DOTENV_DISABLED: 'true' + PYTEST_ADDOPTS: '' + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - stage: Plan + displayName: 'Select samples and generate deployment plan (no Azure changes)' + jobs: + - job: Plan + timeoutInMinutes: 45 + pool: + name: azsdk-pool + image: ubuntu-24.04 + os: linux + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/sample-plan + artifactName: sample-plan + sbomEnabled: false + steps: + - checkout: self + - template: /eng/pipelines/templates/steps/use-python-version.yml + parameters: + versionSpec: '3.12' + - template: /eng/pipelines/templates/steps/auth-dev-feed.yml + parameters: + EnableTwineAuth: false + EnableUvAuth: false + - pwsh: | + python -m pip install --disable-pip-version-check -r dev_requirements.txt -e . + if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' } + workingDirectory: $(packageRoot) + displayName: 'Install SDK source and sample harness dependencies' + - pwsh: | + $outputDirectory = '$(Build.ArtifactStagingDirectory)/sample-plan' + $manifest = Join-Path $outputDirectory 'selected-tests.json' + python tests/samples/ci/select_samples.py --selection-kind $env:SELECTION_KIND --selection $env:SAMPLE_SELECTION --queue-count $env:SAMPLE_QUEUE_COUNT --output $manifest + if ($LASTEXITCODE -ne 0) { throw 'No valid sample selection; no resources were provisioned.' } + $matrix = Get-Content -LiteralPath (Join-Path $outputDirectory 'queue-matrix.json') -Raw | ConvertFrom-Json -AsHashtable + $runnable = [ordered]@{} + foreach ($queueName in @($matrix.Keys | Sort-Object)) { + & ./tests/samples/ci/New-SampleDeploymentPlan.ps1 -SelectionManifest (Join-Path $outputDirectory "$queueName/selected-tests.json") ` + -OutputDirectory (Join-Path $outputDirectory $queueName) -QueueName $queueName ` + -IdentityResourceGroup $env:SAMPLE_IDENTITY_RESOURCE_GROUP -ModelName $env:SAMPLE_MODEL -ModelVersion $env:SAMPLE_MODEL_VERSION ` + -ModelSku $env:SAMPLE_MODEL_SKU -ModelCapacity ([int] $env:SAMPLE_MODEL_CAPACITY) + # A queue whose samples were all rejected has no plan and gets no billable worker. + if (Test-Path -LiteralPath (Join-Path $outputDirectory "$queueName/deployment-plan.json")) { $runnable[$queueName] = $matrix[$queueName] } + } + $rejected = @(Get-ChildItem -LiteralPath $outputDirectory -Filter 'rejected-tests.json' -Recurse -File | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json } | ForEach-Object { $_ }) + foreach ($item in $rejected) { + Write-Host "##vso[task.logissue type=error]Sample test rejected during planning (unsupported environment: $($item.unsupported -join ', ')): $($item.node_id)" + } + if ($runnable.Count -eq 0) { throw 'No selected sample test can run; no resources were provisioned.' } + if ($rejected.Count -gt 0) { Write-Host '##vso[task.complete result=SucceededWithIssues;]Some selected samples were rejected; the rest continue.' } + Write-Host "##vso[task.setvariable variable=RejectedTestCount;isOutput=true]$($rejected.Count)" + $matrixJson = $runnable | ConvertTo-Json -Compress + Write-Host "##vso[task.setvariable variable=WorkerMatrix;isOutput=true]$matrixJson" + name: Plan + workingDirectory: $(packageRoot) + displayName: 'Show selected sample paths, exact pytest IDs, and a fresh deployment plan' + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + SELECTION_KIND: ${{ parameters.selectionKind }} + SAMPLE_SELECTION: ${{ parameters.selection }} + SAMPLE_IDENTITY_RESOURCE_GROUP: ${{ parameters.identityResourceGroup }} + SAMPLE_MODEL: ${{ parameters.modelName }} + SAMPLE_MODEL_VERSION: ${{ parameters.modelVersion }} + SAMPLE_MODEL_SKU: ${{ parameters.modelSku }} + SAMPLE_MODEL_CAPACITY: ${{ parameters.modelCapacity }} + SAMPLE_QUEUE_COUNT: ${{ parameters.testQueueCount }} + + - stage: RunSamples + displayName: 'Provision billable resources -> sample pytest -> run-owned cleanup' + dependsOn: Plan + condition: succeeded() + jobs: + - job: AdmitTestQueues + displayName: 'Check shared identities and aggregate capacity without creating resources' + timeoutInMinutes: 20 + pool: + name: azsdk-pool + image: ubuntu-24.04 + os: linux + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/resolved-sample-plan + artifactName: resolved-sample-plan + sbomEnabled: false + steps: + - checkout: self + - task: DownloadPipelineArtifact@2 + inputs: + artifactName: sample-plan + targetPath: $(Pipeline.Workspace)/sample-plan + - task: AzureCLI@2 + displayName: 'Verify shared MI grants, no leftover RGs and batch quota' + inputs: + azureSubscription: ${{ variables.azureServiceConnection }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + & '$(packageRoot)/tests/samples/ci/Invoke-SampleProvisioning.ps1' ` + -BatchPlanDirectory '$(Pipeline.Workspace)/sample-plan' ` + -ResolvedPlanDirectory '$(Build.ArtifactStagingDirectory)/resolved-sample-plan' + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + - job: ProvisionTestCleanup + dependsOn: AdmitTestQueues + strategy: + maxParallel: 4 + matrix: $[ stageDependencies.Plan.Plan.outputs['Plan.WorkerMatrix'] ] + timeoutInMinutes: 180 + cancelTimeoutInMinutes: 30 + pool: + name: azsdk-pool + image: ubuntu-24.04 + os: linux + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/sample-results/$(QueueName) + artifactName: sample-results-$(QueueName) + condition: always() + sbomEnabled: false + steps: + - checkout: self + - template: /eng/pipelines/templates/steps/use-python-version.yml + parameters: + versionSpec: '3.12' + - template: /eng/pipelines/templates/steps/auth-dev-feed.yml + parameters: + EnableTwineAuth: false + EnableUvAuth: false + - pwsh: | + New-Item -ItemType Directory -Path '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)' -Force | Out-Null + python -m pip install --disable-pip-version-check -r dev_requirements.txt -e . + if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' } + workingDirectory: $(packageRoot) + displayName: 'Install SDK source and live sample dependencies' + - task: DownloadPipelineArtifact@2 + inputs: + artifactName: resolved-sample-plan + targetPath: $(Pipeline.Workspace)/sample-plan + displayName: 'Use this run''s collected sample selection and deployment plan' + - task: AzureCLI@2 + displayName: 'Identify pipeline application and validate target configuration before provisioning' + inputs: + azureSubscription: ${{ variables.azureServiceConnection }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + . '$(packageRoot)/tests/samples/ci/SampleCiHelpers.ps1' + $samplePlan = Get-Content -LiteralPath '$(Pipeline.Workspace)/sample-plan/$(QueueName)/deployment-plan.json' -Raw | ConvertFrom-Json -AsHashtable + $runnerIdentity = Set-SampleSubscriptionContext -Plan $samplePlan + $runnerIdentity | ConvertTo-Json | Set-Content -LiteralPath '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/pipeline-identity.json' -Encoding utf8 + Write-Host ('Pipeline application: principalId={0}; clientId={1}; tenantId={2}' -f $runnerIdentity.principalId, $runnerIdentity.clientId, $runnerIdentity.tenantId) + python '$(packageRoot)/tests/samples/ci/run_samples.py' --check-validation-config + if ($LASTEXITCODE -ne 0) { throw 'The existing validation-project environment variable is missing or invalid; no resources were provisioned.' } + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + LLM_VALIDATION_PROJECT_ENDPOINT: $(LLM_VALIDATION_PROJECT_ENDPOINT) + SAMPLE_QUEUE_NAME: $(QueueName) + - task: AzureCLI@2 + displayName: 'Provision Standard Foundry, sample model and MCP connection using Bicep' + inputs: + azureSubscription: ${{ variables.azureServiceConnection }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + & '$(packageRoot)/tests/samples/ci/Invoke-SampleProvisioning.ps1' ` + -PlanPath '$(Pipeline.Workspace)/sample-plan/$(QueueName)/deployment-plan.json' ` + -StatePath '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/deployment-state.json' ` + -QueueName $env:SAMPLE_QUEUE_NAME -ConfirmProvisioning + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + MCP_GITHUB_PAT: $(MCP_GITHUB_PAT) + PROJ_CONN_OPEN_API_KEY: $(PROJ_CONN_OPEN_API_KEY) + SAMPLE_QUEUE_NAME: $(QueueName) + - task: AzureCLI@2 + displayName: 'Run selected test and collect results' + inputs: + azureSubscription: ${{ variables.azureServiceConnection }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + . '$(packageRoot)/tests/samples/ci/SampleCiHelpers.ps1' + $sampleState = Get-Content -LiteralPath '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/deployment-state.json' -Raw | ConvertFrom-Json -AsHashtable + $null = Set-SampleSubscriptionContext -Plan $sampleState.plan + python '$(packageRoot)/tests/samples/ci/run_samples.py' ` + --state '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/deployment-state.json' ` + --junit '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/sample-tests.xml' + $sampleExitCode = $LASTEXITCODE + if ($sampleExitCode -ne 0) { + throw 'Selected sample runner failed; inspect the task output and pytest results when present.' + } + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + LLM_VALIDATION_PROJECT_ENDPOINT: $(LLM_VALIDATION_PROJECT_ENDPOINT) + SAMPLE_QUEUE_NAME: $(QueueName) + - task: AzureCLI@2 + displayName: 'Always clean up only the run-owned temporary resources' + condition: always() + inputs: + azureSubscription: ${{ variables.azureServiceConnection }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + & '$(packageRoot)/tests/samples/ci/Remove-SampleResources.ps1' ` + -StatePath '$(Build.ArtifactStagingDirectory)/sample-results/$(QueueName)/deployment-state.json' ` + -QueueName $env:SAMPLE_QUEUE_NAME -ConfirmCleanup + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + SAMPLE_QUEUE_NAME: $(QueueName) + - task: PublishTestResults@2 + displayName: 'Publish sample pytest results in the ADO Tests tab' + condition: always() + inputs: + testResultsFormat: JUnit + testResultsFiles: sample-tests.xml + searchFolder: $(Build.ArtifactStagingDirectory)/sample-results/$(QueueName) + testRunTitle: 'Foundry sample tests - $(Build.BuildNumber) - $(QueueName)' + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: false + failTaskOnFailureToPublishResults: true + publishRunAttachments: true + - job: ReportRejectedSamples + displayName: 'Fail the run for samples rejected during planning' + dependsOn: ProvisionTestCleanup + condition: succeededOrFailed() + timeoutInMinutes: 5 + variables: + rejectedCount: $[ stageDependencies.Plan.Plan.outputs['Plan.RejectedTestCount'] ] + pool: + name: azsdk-pool + image: ubuntu-24.04 + os: linux + steps: + - checkout: none + - pwsh: | + if ([int] $env:REJECTED_COUNT -gt 0) { + throw "$env:REJECTED_COUNT selected sample test(s) were rejected during planning for unsupported environment variables; see the Plan stage errors. The remaining samples were run." + } + displayName: 'Fail if any selected sample could not be planned' + env: + REJECTED_COUNT: $(rejectedCount) diff --git a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_start_span_attributes.py b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_start_span_attributes.py new file mode 100644 index 000000000000..642b0b531282 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_start_span_attributes.py @@ -0,0 +1,49 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +""" +Tests for the request attributes that ``start_span`` records on a recording span. + +These tests replace the tracing implementation with a mock span factory, so no +OpenTelemetry exporter or network access is needed. +""" + +from unittest.mock import MagicMock, patch + +from azure.ai.projects.telemetry import _utils +from azure.ai.projects.telemetry._utils import ( + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_P, + OperationName, + start_span, +) + + +def _make_recording_span_factory(): + span = MagicMock() + span.span_instance.is_recording = MagicMock(return_value=True) + return MagicMock(return_value=span), span + + +class TestStartSpanAttributes: + """Verify start_span records request parameters, including zero values.""" + + def test_zero_temperature_and_top_p_are_recorded(self): + factory, span = _make_recording_span_factory() + + with patch.object(_utils, "_span_impl_type", factory): + start_span(OperationName.CREATE_AGENT, server_address="example.com", temperature=0.0, top_p=0.0) + + span.add_attribute.assert_any_call(GEN_AI_REQUEST_TEMPERATURE, "0.0") + span.add_attribute.assert_any_call(GEN_AI_REQUEST_TOP_P, "0.0") + + def test_unset_temperature_and_top_p_are_not_recorded(self): + factory, span = _make_recording_span_factory() + + with patch.object(_utils, "_span_impl_type", factory): + start_span(OperationName.CREATE_AGENT, server_address="example.com") + + recorded_names = [call.args[0] for call in span.add_attribute.call_args_list] + assert GEN_AI_REQUEST_TEMPERATURE not in recorded_names + assert GEN_AI_REQUEST_TOP_P not in recorded_names diff --git a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_voice_agents_instrumentor.py b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_voice_agents_instrumentor.py new file mode 100644 index 000000000000..0d02753e8620 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_voice_agents_instrumentor.py @@ -0,0 +1,1416 @@ +# pylint: disable=too-many-lines +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Deterministic sync/async lifecycle and privacy tests for Voice Agents tracing.""" + +import asyncio +import base64 +import io +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +import requests +from urllib3.response import HTTPResponse +from opentelemetry import trace +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF +from opentelemetry.trace import StatusCode + +from azure.core import MatchConditions +from azure.core.credentials import AccessToken +from azure.core.exceptions import ResourceNotFoundError + +# Core conditionally re-exports this optional transport; runtime imports are covered below. +from azure.core.pipeline.transport import RequestsTransport # pylint: disable=no-name-in-module +from azure.core.settings import settings +from azure.ai.projects import AIProjectClient, models +from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient +from azure.ai.projects.telemetry import AIProjectInstrumentor, _utils +from azure.ai.projects.telemetry import _voice_agents_instrumentor as voice +from azure.ai.projects.telemetry import _responses_instrumentor as responses +from azure.ai.projects._realtime import BetaRealtimeConnectionManager +from azure.ai.projects.aio._realtime import AsyncBetaRealtimeConnectionManager + +_ENDPOINT = "https://example.test/api/projects/project-test" +_PREFIX = "az.ai.voice_agents." +_SECRET = "PRIVATE-CONTENT-DO-NOT-EXPORT" + + +@pytest.fixture(name="telemetry") +def _telemetry(monkeypatch): + original_impl = settings.tracing_implementation() + original_enabled = settings.tracing_enabled() + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "_TRACER_PROVIDER", provider) + monkeypatch.setenv("AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING", "true") + settings.tracing_implementation = "opentelemetry" + settings.tracing_enabled = True + monkeypatch.setattr(_utils, "_span_impl_type", settings.tracing_implementation()) + instrumentor = AIProjectInstrumentor() + instrumentor.uninstrument() + instrumentor.instrument(enable_content_recording=False) + reader = InMemoryMetricReader() + meter_provider = MeterProvider(metric_readers=[reader]) + meter = meter_provider.get_meter(__name__) + monkeypatch.setattr( + responses, "_operation_duration_histogram", meter.create_histogram("gen_ai.client.operation.duration", unit="s") + ) + monkeypatch.setattr( + responses, "_token_usage_histogram", meter.create_histogram("gen_ai.client.token.usage", unit="token") + ) + yield SimpleNamespace( + instrumentor=instrumentor, exporter=exporter, provider=provider, monkeypatch=monkeypatch, metric_reader=reader + ) + instrumentor.uninstrument() + provider.shutdown() + meter_provider.shutdown() + settings.tracing_implementation = original_impl + settings.tracing_enabled = original_enabled + + +def _events(): + return [ + { + "type": "session.created", + "session": {"id": "session-test", "model": "test-model"}, + "conversation_id": "conversation-test", + }, + {"type": "session.updated", "session": {"id": "session-test", "model": "test-model"}}, + {"type": "response.created", "response": {"id": "response-test", "conversation_id": "conversation-test"}}, + {"type": "response.output_text.delta", "response_id": "response-test", "delta": _SECRET}, + { + "type": "response.output_audio.delta", + "response_id": "response-test", + "delta": base64.b64encode(b"\x01\x00" * 12).decode(), + }, + { + "type": "response.function_call_arguments.done", + "response_id": "response-test", + "call_id": "call-test", + "name": _SECRET, + "arguments": _SECRET, + }, + { + "type": "response.output_text.done", + "response_id": "response-test", + "item_id": "item-test", + "content_index": 0, + "text": _SECRET, + }, + { + "type": "response.done", + "response": { + "id": "response-test", + "status": "completed", + "output_modalities": ["text"], + "output": [ + { + "id": "item-test", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": _SECRET}], + } + ], + "usage": {"input_tokens": 3, "output_tokens": 7}, + }, + }, + ] + + +def _manager(monkeypatch, is_async, events=None, failure=None, headers=None): + credential = MagicMock() + token = AccessToken(_SECRET, 4_102_444_800) + ws = MagicMock() + ws.close_code = 1000 + ws.closed = False + manager: BetaRealtimeConnectionManager | AsyncBetaRealtimeConnectionManager + if is_async: + import aiohttp + + messages = [ + SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(event)) for event in (events or _events()) + ] + ws.receive = AsyncMock(side_effect=messages) + ws.send_str = AsyncMock() + ws.close = AsyncMock() + session = MagicMock() + session.close = AsyncMock() + session.ws_connect = AsyncMock(side_effect=failure, return_value=ws) + credential.get_token = AsyncMock(return_value=token) + monkeypatch.setattr(aiohttp, "ClientSession", MagicMock(return_value=session)) + manager = AsyncBetaRealtimeConnectionManager( + endpoint=_ENDPOINT, + credential=credential, + credential_scopes=["https://ai.azure.com/.default"], + api_version="v1", + agent_name="agent-test", + extra_headers=headers, + ) + connect = session.ws_connect + else: + import websockets.sync.client + + ws.recv.side_effect = [json.dumps(event) for event in (events or _events())] + credential.get_token.return_value = token + connect = MagicMock(side_effect=failure, return_value=ws) + monkeypatch.setattr(websockets.sync.client, "connect", connect) + manager = BetaRealtimeConnectionManager( + endpoint=_ENDPOINT, + credential=credential, + credential_scopes=["https://ai.azure.com/.default"], + api_version="v1", + agent_name="agent-test", + extra_headers=headers, + ) + return manager, ws, connect + + +async def _call(connection, method, is_async, **kwargs): + result = getattr(connection, method)(**kwargs) + return await result if is_async else result + + +def _spans(telemetry, name): + return [span for span in telemetry.exporter.get_finished_spans() if span.name == name] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +async def test_live_protocol_hierarchy_content_and_audio_accounting(telemetry, is_async, content): + telemetry.instrumentor.instrument(enable_content_recording=content) + manager, _, connect = _manager(telemetry.monkeypatch, is_async) + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("application") as parent: + connection = await manager.enter() if is_async else manager.enter() + assert trace.get_current_span() is parent + await _call( + connection, + "send", + is_async, + event={"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\0\0" * 8).decode()}, + ) + await _call( + connection, + "send", + is_async, + event={ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": _SECRET}], + }, + }, + ) + for _ in _events(): + event = await _call(connection, "recv", is_async) + assert event.get("type") + await _call( + connection, + "send", + is_async, + event={ + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": "call-test", + "output": _SECRET, + }, + }, + ) + await _call(connection, "close", is_async, reason=_SECRET) + assert trace.get_current_span() is parent + spans = telemetry.exporter.get_finished_spans() + (session,) = _spans(telemetry, "voice_agents.realtime.session") + (handshake,) = _spans(telemetry, "voice_agents.realtime.connect") + (response,) = _spans(telemetry, "invoke_agent") + assert len(spans) == 4 + assert session.parent.span_id == parent.get_span_context().span_id + assert handshake.parent.span_id == session.context.span_id + assert response.parent.span_id == session.context.span_id + assert len({span.context.trace_id for span in spans}) == 1 + assert session.attributes["gen_ai.provider.name"] == "microsoft.foundry" + assert session.attributes["az.namespace"] == "Microsoft.CognitiveServices" + assert session.attributes["server.address"] == "example.test" + assert session.attributes[_PREFIX + "input.audio.bytes"] == 16 + assert session.attributes[_PREFIX + "output.audio.bytes"] == 24 + assert response.attributes[_PREFIX + "output.audio.bytes"] == 24 + assert response.attributes[_PREFIX + "response.time_to_first_audio_seconds"] >= 0 + assert response.attributes["gen_ai.usage.input_tokens"] == 3 + assert response.status.status_code is StatusCode.OK + assert any(event.name == "voice_agents.tool.requested" for event in response.events) + assert any(event.name == "voice_agents.tool.output_sent" for event in session.events) + exported = json.dumps([json.loads(span.to_json()) for span in spans]) + assert (_SECRET in exported) is content + assert base64.b64encode(b"\x01\x00" * 12).decode() not in exported + headers = connect.call_args.kwargs["headers" if is_async else "additional_headers"] + assert headers["traceparent"].split("-")[1] == format(session.context.trace_id, "032x") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_disabled_and_uninstrumented_have_no_voice_spans(telemetry, is_async): + telemetry.instrumentor.uninstrument() + manager, _, connect = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + await _call(connection, "close", is_async) + assert not telemetry.exporter.get_finished_spans() + assert "traceparent" not in connect.call_args.kwargs["headers" if is_async else "additional_headers"] + assert not voice._ORIGINALS + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_connection_failure_closes_both_spans_without_error_content(telemetry, is_async): + manager, _, _ = _manager(telemetry.monkeypatch, is_async, failure=OSError(_SECRET)) + with pytest.raises(ConnectionError): + if is_async: + await manager.enter() + else: + manager.enter() + spans = telemetry.exporter.get_finished_spans() + assert {span.name for span in spans} == {"voice_agents.realtime.session", "voice_agents.realtime.connect"} + assert all(span.status.status_code is StatusCode.ERROR for span in spans) + assert _SECRET not in "".join(span.to_json() for span in spans) + assert not voice._SESSIONS + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_interrupted_response_and_repeated_close_end_only_once(telemetry, is_async): + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=_events()[:3]) + connection = await manager.enter() if is_async else manager.enter() + for _ in range(3): + await _call(connection, "recv", is_async) + await _call(connection, "send", is_async, event={"type": "response.cancel", "response_id": "response-test"}) + await _call(connection, "close", is_async) + await _call(connection, "close", is_async) + (response,) = _spans(telemetry, "invoke_agent") + assert response.attributes[_PREFIX + "response.status"] == "interrupted" + assert response.status.status_code is not StatusCode.ERROR + assert len(telemetry.exporter.get_finished_spans()) == 3 + + +def _rest_client(payload, status=200): + response = requests.Response() + response.status_code = status + response.reason = "test" + response.headers["content-type"] = "application/json" + response._content = json.dumps(payload).encode() + response._content_consumed = True + response.raw = HTTPResponse(body=io.BytesIO(response._content), preload_content=False) + transport_session = MagicMock() + transport_session.request.return_value = response + credential = MagicMock() + credential.get_token.return_value = AccessToken("fake-token", 4_102_444_800) + client = AIProjectClient( + endpoint=_ENDPOINT, + credential=credential, + allow_preview=True, + transport=RequestsTransport(session=transport_session, session_owner=False), + ) + return client + + +def test_voice_creation_reuses_foundry_span_without_duplicates(telemetry): + definition = models.VoiceAgentDefinition(model="test-model", model_type="managed", instructions=_SECRET) + with _rest_client( + {"id": "agent-test:1", "name": "agent-test", "version": "1", "definition": definition.as_dict()} + ) as client: + result = client.agents.create_version("agent-test", definition=definition) + assert result.version == "1" + (span,) = _spans(telemetry, "create_agent") + assert span.attributes["gen_ai.agent.type"] == "voice" + assert span.attributes["gen_ai.agent.version"] == "1" + assert _SECRET not in span.to_json() + assert not _spans(telemetry, "create_agent agent-test") + assert not [span for span in telemetry.exporter.get_finished_spans() if span.name.endswith(".create_version")] + + +def test_rest_delete_error_status_has_no_raw_service_error(telemetry): + with _rest_client({"error": {"code": "not_found", "message": _SECRET}}, status=404) as client: + with pytest.raises(ResourceNotFoundError): + client.beta.voice_agents.conversations.delete("agent-test", "conversation-test") + (span,) = _spans(telemetry, "voice_agents.conversations.delete") + assert span.status.status_code is StatusCode.ERROR + assert span.attributes["http.response.status_code"] == 404 + assert not span.events + assert _SECRET not in span.to_json() + + +def test_opt_out_per_rest_call_and_experimental_gate(telemetry): + with _rest_client({}, status=204) as client: + client.beta.voice_agents.conversations.delete( + "agent-test", "conversation-test", tracing_options={"enabled": False} + ) + assert not _spans(telemetry, "voice_agents.conversations.delete") + telemetry.instrumentor.uninstrument() + telemetry.monkeypatch.setenv("AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING", "false") + telemetry.instrumentor.instrument() + assert not voice._ORIGINALS + + +@pytest.mark.asyncio +async def test_cross_task_response_parent_and_context_are_preserved(telemetry): + manager, _, _ = _manager(telemetry.monkeypatch, True) + with trace.get_tracer(__name__).start_as_current_span("application") as parent: + connection = await manager.enter() + + async def receive(): + assert trace.get_current_span().get_span_context().span_id == 0 + for _ in _events(): + await connection.recv() + await connection.close() + + await asyncio.create_task(receive()) + (session,) = _spans(telemetry, "voice_agents.realtime.session") + (response,) = _spans(telemetry, "invoke_agent") + assert session.parent.span_id == parent.get_span_context().span_id + assert response.parent.span_id == session.context.span_id + + +def test_non_recording_avoids_sensitive_mutations(telemetry): + provider = TracerProvider(sampler=ALWAYS_OFF) + telemetry.monkeypatch.setattr(trace, "_TRACER_PROVIDER", provider) + manager, _, _ = _manager(telemetry.monkeypatch, False) + with manager as connection: + for _ in _events(): + connection.recv() + assert not telemetry.exporter.get_finished_spans() + assert not voice._SESSIONS + provider.shutdown() + + +def test_provider_failure_does_not_change_socket_results(telemetry, caplog): + manager, _, _ = _manager(telemetry.monkeypatch, False) + telemetry.monkeypatch.setattr(voice, "_start", MagicMock(side_effect=RuntimeError(_SECRET))) + with manager as connection: + assert connection.recv().type == "session.created" + assert "Unable to record Voice Agents telemetry." in caplog.text + assert _SECRET not in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("status", ["completed", "cancelled", "failed"]) +async def test_response_status_and_shared_metrics(telemetry, is_async, status): + events = _events() + events[-1]["response"]["status"] = status + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + for _ in events: + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "invoke_agent") + assert span.attributes[_PREFIX + "response.status"] == status + assert (span.status.status_code is StatusCode.ERROR) == (status == "failed") + if status == "cancelled": + assert span.attributes[_PREFIX + "cancelled"] is True + metrics_data = telemetry.metric_reader.get_metrics_data() + metrics = { + metric.name: metric + for resource in metrics_data.resource_metrics + for scope in resource.scope_metrics + for metric in scope.metrics + } + (duration,) = metrics["gen_ai.client.operation.duration"].data.data_points + assert duration.count == 1 + assert duration.sum >= 0 + assert duration.attributes["gen_ai.operation.name"] == "invoke_agent" + usage = metrics["gen_ai.client.token.usage"].data.data_points + assert sorted(point.sum for point in usage) == [3, 7] + assert all("gen_ai.response.id" not in point.attributes for point in usage) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_server_errors_and_transport_errors_are_sanitized(telemetry, is_async): + events = [ + {"type": "error", "error": {"type": _SECRET, "code": _SECRET, "message": _SECRET}}, + {"type": "error", "error": {"type": "invalid_request_error", "code": "invalid_event", "message": _SECRET}}, + {"type": "warning", "message": _SECRET}, + ] + manager, ws, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + for _ in events: + await _call(connection, "recv", is_async) + if is_async: + ws.receive.side_effect = ConnectionResetError(_SECRET) + else: + ws.recv.side_effect = ConnectionResetError(_SECRET) + ws.close_code = 1011 + with pytest.raises(ConnectionResetError): + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (session,) = _spans(telemetry, "voice_agents.realtime.session") + assert session.status.status_code is StatusCode.ERROR + assert session.attributes[_PREFIX + "close.was_clean"] is False + assert session.events[0].attributes["error.type"] == "Error" + assert session.events[1].attributes[_PREFIX + "error.code"] == "invalid_event" + assert _SECRET not in session.to_json() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_connection_context_error_and_cancellation_preserve_exception(telemetry, is_async): + manager, _, _ = _manager(telemetry.monkeypatch, is_async) + error = asyncio.CancelledError(_SECRET) if is_async else ValueError(_SECRET) + with pytest.raises(type(error)): + if is_async: + async with manager: + raise error + else: + with manager: + raise error + (session,) = _spans(telemetry, "voice_agents.realtime.session") + if is_async: + assert session.attributes[_PREFIX + "cancelled"] is True + assert session.status.status_code is not StatusCode.ERROR + else: + assert session.status.status_code is StatusCode.ERROR + assert _SECRET not in session.to_json() + + +def test_uninstrument_ends_open_spans_and_restores_methods(telemetry): + manager, _, _ = _manager(telemetry.monkeypatch, False, events=_events()[:3]) + connection = manager.enter() + for _ in range(3): + connection.recv() + AIProjectInstrumentor().uninstrument() + assert len(_spans(telemetry, "invoke_agent")) == 1 + assert len(_spans(telemetry, "voice_agents.realtime.session")) == 1 + assert not voice._ORIGINALS and not voice._SESSIONS + connection.close() + assert len(telemetry.exporter.get_finished_spans()) == 3 + + +def test_response_and_content_tracking_are_bounded(telemetry): + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + for index in range(voice._MAX_RESPONSES + 5): + state.received({"type": "response.created", "response": {"id": f"response-{index}"}}) + assert len(state.responses) == voice._MAX_RESPONSES + assert state.untracked == 5 + for index in range(voice._MAX_PARTS + 5): + state.received( + { + "type": "response.output_text.done", + "response_id": "response-0", + "item_id": f"item-{index}", + "content_index": index, + "text": "X" * (voice._MAX_CONTENT + 5), + } + ) + assert len(state.responses["response-0"].parts) == voice._MAX_PARTS + assert all(len(part["content"]) == voice._MAX_CONTENT for part in state.responses["response-0"].parts.values()) + connection.close() + assert not state.responses + + +@pytest.mark.parametrize("content", [False, True]) +def test_content_environment_uses_foundry_precedence(telemetry, content): + telemetry.monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", str(content)) + telemetry.instrumentor.instrument() + assert telemetry.instrumentor.is_content_recording_enabled() is content + definition = models.VoiceAgentDefinition(model="test-model", model_type="managed", instructions=_SECRET) + with _rest_client( + {"id": "agent-test:1", "name": "agent-test", "version": "1", "definition": definition.as_dict()} + ) as client: + client.agents.create_version("agent-test", definition=definition) + (span,) = _spans(telemetry, "create_agent") + assert (_SECRET in span.to_json()) is content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_propagation_opt_out_and_existing_header_preserved(telemetry, is_async): + telemetry.instrumentor.instrument(enable_trace_context_propagation=False) + manager, _, connect = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + await _call(connection, "close", is_async) + assert "traceparent" not in connect.call_args.kwargs["headers" if is_async else "additional_headers"] + telemetry.instrumentor.instrument(enable_trace_context_propagation=True) + supplied = {"TraceParent": "application-provided-context"} + manager, _, connect = _manager(telemetry.monkeypatch, is_async, headers=supplied) + connection = await manager.enter() if is_async else manager.enter() + await _call(connection, "close", is_async) + sent = connect.call_args.kwargs["headers" if is_async else "additional_headers"] + assert sent["TraceParent"] == "application-provided-context" + assert "traceparent" not in sent + assert supplied == {"TraceParent": "application-provided-context"} + + +@pytest.mark.asyncio +async def test_async_generation_rest_uses_same_span_and_no_new_public_api(telemetry): + response = SimpleNamespace( + status_code=200, + headers={}, + json=lambda: { + "id": "agent-test", + "name": "agent-test", + "versions": { + "latest": { + "id": "agent-test:1", + "name": "agent-test", + "version": "1", + "definition": {"kind": "voice", "model": "test-model", "model_type": "managed"}, + } + }, + }, + ) + credential = MagicMock() + credential.get_token = AsyncMock(return_value=AccessToken("fake-token", 4_102_444_800)) + async with AsyncAIProjectClient(endpoint=_ENDPOINT, credential=credential, allow_preview=True) as client: + telemetry.monkeypatch.setattr( + client._client._pipeline, "run", AsyncMock(return_value=SimpleNamespace(http_response=response)) + ) + result = await client.beta.agents.create_from_prompt( + models.GenerateVoiceAgentRequest(kind=models.AgentKind.VOICE, name="agent-test") + ) + assert result.name == "agent-test" + (span,) = _spans(telemetry, "create_agent") + assert span.attributes["gen_ai.request.model"] == "test-model" + assert span.attributes["gen_ai.agent.type"] == "voice" + + +def test_non_voice_existing_create_span_is_unchanged(telemetry): + definition = models.PromptAgentDefinition(model="test-model", instructions="public test prompt") + with _rest_client( + {"id": "agent-test:1", "name": "agent-test", "version": "1", "definition": definition.as_dict()} + ) as client: + client.agents.create_version(agent_name="agent-test", definition=definition) + (span,) = _spans(telemetry, "create_agent agent-test") + assert span.attributes["gen_ai.agent.type"] == "prompt" + assert not _spans(telemetry, "create_agent") + + +@pytest.mark.parametrize("backend", [None, "opentelemetry"]) +@pytest.mark.parametrize("experimental", [False, True]) +def test_voice_and_prompt_share_global_tracing_configuration(telemetry, backend, experimental): + telemetry.instrumentor.uninstrument() + settings.tracing_implementation = backend + telemetry.monkeypatch.setenv("AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING", str(experimental).lower()) + telemetry.instrumentor.instrument() + for definition in ( + models.VoiceAgentDefinition(model="test-model", model_type="managed", instructions="public test prompt"), + models.PromptAgentDefinition(model="test-model", instructions="public test prompt"), + ): + with _rest_client( + {"id": "agent-test:1", "name": "agent-test", "version": "1", "definition": definition.as_dict()} + ) as client: + client.agents.create_version(agent_name="agent-test", definition=definition) + enabled = experimental and backend is not None + assert len(_spans(telemetry, "create_agent")) == int(enabled) + assert len(_spans(telemetry, "create_agent agent-test")) == int(enabled) + + +_MUTATIONS = [ + ( + "create_call_job", + ("agent-test", {"binding_id": "binding-test", "phone_number": _SECRET}), + {"idempotency_key": "key-test"}, + ), + ( + "cancel_call_job", + ("agent-test", "call-job-test"), + {"etag": "etag-test", "match_condition": MatchConditions.IfNotModified}, + ), + ("transfer_call", ("agent-test", "call-test"), {"target": _SECRET}), + ("end_call", ("agent-test", "call-test"), {}), + ("create_binding", ("agent-test", {"provider": "twilio", "connection_name": _SECRET, "phone_number": _SECRET}), {}), + ( + "update_binding", + ("agent-test", "binding-test", {"phone_number": _SECRET}), + {"etag": "etag-test", "match_condition": MatchConditions.IfNotModified}, + ), + ( + "delete_binding", + ("agent-test", "binding-test"), + {"etag": "etag-test", "match_condition": MatchConditions.IfNotModified}, + ), + ( + "replace_transfer_targets", + ("agent-test",), + {"transfer_targets": [], "etag": "etag-test", "match_condition": MatchConditions.IfNotModified}, + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("method,args,kwargs", _MUTATIONS, ids=[item[0] for item in _MUTATIONS]) +async def test_telephony_mutations_names_and_safe_errors(telemetry, is_async, method, args, kwargs): + if is_async: + response = SimpleNamespace( + status_code=404, + reason="Not Found", + headers={}, + json=lambda: {"error": {"code": "not_found", "message": _SECRET}}, + text=lambda: _SECRET, + ) + credential = MagicMock() + credential.get_token = AsyncMock(return_value=AccessToken("fake-token", 4_102_444_800)) + async with AsyncAIProjectClient(endpoint=_ENDPOINT, credential=credential, allow_preview=True) as client: + telemetry.monkeypatch.setattr( + client._client._pipeline, "run", AsyncMock(return_value=SimpleNamespace(http_response=response)) + ) + with pytest.raises(ResourceNotFoundError): + await getattr(client.beta.voice_agents.telephony, method)(*args, **kwargs) + else: + with _rest_client({"error": {"code": "not_found", "message": _SECRET}}, status=404) as client: + with pytest.raises(ResourceNotFoundError): + getattr(client.beta.voice_agents.telephony, method)(*args, **kwargs) + (span,) = _spans(telemetry, "voice_agents.telephony." + method) + assert span.attributes[_PREFIX + "operation"] == method + assert span.attributes["http.response.status_code"] == 404 + assert span.status.status_code is StatusCode.ERROR + assert _SECRET not in span.to_json() + + +def test_explicit_rest_enable_overrides_global_disable(telemetry): + settings.tracing_enabled = False + with _rest_client({}, status=204) as client: + client.beta.voice_agents.conversations.delete( + "agent-test", "conversation-test", tracing_options={"enabled": True} + ) + assert len(_spans(telemetry, "voice_agents.conversations.delete")) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("include_baggage", [False, True]) +async def test_baggage_requires_existing_explicit_opt_in(telemetry, is_async, include_baggage): + from opentelemetry import baggage, context + + telemetry.instrumentor.instrument(enable_baggage_propagation=include_baggage) + token = context.attach(baggage.set_baggage("public-test-key", "public-test-value")) + try: + manager, _, connect = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + await _call(connection, "close", is_async) + finally: + context.detach(token) + headers = connect.call_args.kwargs["headers" if is_async else "additional_headers"] + assert ("baggage" in headers) is include_baggage + assert "public-test-value" not in "".join(span.to_json() for span in telemetry.exporter.get_finished_spans()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_failed_audio_send_is_not_counted(telemetry, is_async): + manager, ws, _ = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + if is_async: + ws.send_str.side_effect = TimeoutError(_SECRET) + else: + ws.send.side_effect = TimeoutError(_SECRET) + with pytest.raises(TimeoutError): + await _call( + connection, + "send", + is_async, + event={"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\0\0" * 12).decode()}, + ) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert span.attributes[_PREFIX + "input.audio.bytes"] == 0 + assert span.events[0].name == "voice_agents.send.failed" + assert _SECRET not in span.to_json() + + +def test_untrusted_close_code_and_binary_opt_in_never_export_media(telemetry): + telemetry.monkeypatch.setenv("AZURE_TRACING_GEN_AI_INCLUDE_BINARY_DATA", "true") + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + for _ in _events(): + connection.recv() + getattr(connection, "_voice_trace").close(code=_SECRET) + connection.close() + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert _PREFIX + "close.code" not in span.attributes + assert _SECRET not in span.to_json() + assert base64.b64encode(b"\x01\x00" * 12).decode() not in "".join( + item.to_json() for item in telemetry.exporter.get_finished_spans() + ) + + +@pytest.mark.parametrize("global_disable", [False, True]) +def test_voice_creation_disable_does_not_fall_back_to_existing_wrapper(telemetry, global_disable): + settings.tracing_enabled = not global_disable + definition = models.VoiceAgentDefinition(model="test-model", model_type="managed", instructions=_SECRET) + with _rest_client( + {"id": "agent-test:1", "name": "agent-test", "version": "1", "definition": definition.as_dict()} + ) as client: + options = {} if global_disable else {"tracing_options": {"enabled": False}} + result = client.agents.create_version(agent_name="agent-test", definition=definition, **options) + assert result.version == "1" + assert not [span for span in telemetry.exporter.get_finished_spans() if span.name.startswith("create_agent")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("audio", [False, True], ids=["tool-result", "audio"]) +async def test_async_send_records_serialized_payload_not_later_mutation(telemetry, audio): + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, ws, _ = _manager(telemetry.monkeypatch, True) + connection = await manager.enter() + sending = asyncio.Event() + release = asyncio.Event() + + async def wait_for_send(_payload): + sending.set() + await release.wait() + + ws.send_str.side_effect = wait_for_send + event: dict[str, Any] = ( + {"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\0\0" * 12).decode()} + if audio + else { + "type": "conversation.item.create", + "item": {"type": "function_call_output", "call_id": "call-test", "output": "original result"}, + } + ) + task = asyncio.create_task(connection.send(event)) + await sending.wait() + if audio: + event["audio"] = base64.b64encode(b"\0\0" * 36).decode() + else: + event["item"]["output"] = _SECRET + release.set() + await task + await connection.close() + (span,) = _spans(telemetry, "voice_agents.realtime.session") + serialized_payload = json.loads(ws.send_str.call_args.args[0]) + if audio: + assert len(base64.b64decode(serialized_payload["audio"])) == 24 + assert span.attributes[_PREFIX + "input.audio.bytes"] == 24 + else: + assert serialized_payload["item"]["output"] == "original result" + assert "original result" in span.attributes["gen_ai.input.messages"] + assert _SECRET not in span.to_json() + + +def test_completed_system_tool_summary_is_not_duplicated(telemetry): + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + state.received({"type": "response.created", "response": {"id": "response-test"}}) + state.received( + { + "type": "response.system_tool_arguments.done", + "response_id": "response-test", + "item_id": "item-system-test", + "call_id": "call-test", + "name": "end_conversation", + "arguments": "{}", + } + ) + state.received( + { + "type": "response.done", + "response": { + "id": "response-test", + "status": "completed", + "output": [ + { + "type": "system_tool", + "id": "item-system-test", + "call_id": "call-test", + "name": "end_conversation", + "arguments": "{}", + } + ], + }, + } + ) + connection.close() + (response,) = _spans(telemetry, "invoke_agent") + messages = json.loads(response.attributes["gen_ai.output.messages"]) + assert len(messages) == 1 + assert len(messages[0]["parts"]) == 1 + assert messages[0]["parts"][0]["name"] == "end_conversation" + + +def test_output_fallback_matches_content_index_and_type(telemetry): + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + state.received({"type": "response.created", "response": {"id": "response-test"}}) + state.received( + { + "type": "response.output_text.done", + "response_id": "response-test", + "item_id": "item-test", + "content_index": 2, + "text": "only third part", + } + ) + state.received( + { + "type": "response.done", + "response": { + "id": "response-test", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "id": "item-test", + "content": [{"type": "output_audio"}, {"type": "output_text"}, {"type": "output_text"}], + } + ], + }, + } + ) + connection.close() + (response,) = _spans(telemetry, "invoke_agent") + parts = json.loads(response.attributes["gen_ai.output.messages"])[0]["parts"] + assert "content" not in parts[0] and "content" not in parts[1] + assert parts[2]["content"] == "only third part" + + +def test_tracking_keys_do_not_retain_oversized_service_identifiers(telemetry): + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + state.received({"type": "response.created", "response": {"id": "response-test"}}) + oversized = "id-" + "X" * (voice._MAX_CONTENT * 2) + state.received( + { + "type": "response.output_text.done", + "response_id": "response-test", + "item_id": oversized, + "content_index": 0, + "text": "public", + } + ) + state.received( + { + "type": "response.function_call_arguments.done", + "response_id": "response-test", + "call_id": oversized, + "name": "test", + "arguments": "{}", + } + ) + tracked = state.responses["response-test"] + assert all(len(key[0]) <= voice._MAX_CONTENT for key in tracked.parts) + assert all(len(key) <= voice._MAX_CONTENT + 5 for key in tracked.messages) + connection.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_abnormal_explicit_close_has_error_status_and_not_clean_flag(telemetry, is_async): + manager, ws, _ = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + ws.close_code = 1011 + await _call(connection, "close", is_async, code=1011) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert span.attributes[_PREFIX + "close.was_clean"] is False + assert span.status.status_code is StatusCode.ERROR + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +async def test_context_failure_marks_pending_responses_failed(telemetry, is_async): + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=_events()[:3]) + with pytest.raises(ValueError): + if is_async: + async with manager as connection: + for _ in range(3): + await connection.recv() + raise ValueError(_SECRET) + else: + with manager as connection: + for _ in range(3): + connection.recv() + raise ValueError(_SECRET) + (span,) = _spans(telemetry, "invoke_agent") + assert span.attributes[_PREFIX + "response.status"] == "interrupted" + assert span.status.status_code is StatusCode.ERROR + assert _SECRET not in span.to_json() + + +@pytest.mark.asyncio +async def test_cancelled_receive_then_local_close_is_cancellation_not_failure(telemetry): + manager, ws, _ = _manager(telemetry.monkeypatch, True) + connection = await manager.enter() + ws.receive.side_effect = asyncio.CancelledError(_SECRET) + with pytest.raises(asyncio.CancelledError): + await connection.recv() + ws.close_code = 1006 + await connection.close() + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert span.attributes[_PREFIX + "cancelled"] is True + assert span.attributes[_PREFIX + "close.code"] == 1006 + assert span.attributes[_PREFIX + "close.was_clean"] is False + assert span.status.status_code is not StatusCode.ERROR + assert span.events[0].name == "voice_agents.receive.cancelled" + assert _SECRET not in span.to_json() + + +@pytest.mark.asyncio +async def test_cancelled_receive_does_not_hide_subsequent_server_failure(telemetry): + import aiohttp + + manager, ws, _ = _manager(telemetry.monkeypatch, True) + connection = await manager.enter() + ws.receive.side_effect = asyncio.CancelledError() + with pytest.raises(asyncio.CancelledError): + await connection.recv() + ws.receive.side_effect = None + ws.receive.return_value = SimpleNamespace(type=aiohttp.WSMsgType.CLOSE, data=None) + ws.close_code = 1011 + with pytest.raises(ConnectionResetError): + await connection.recv() + await connection.close() + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert span.status.status_code is StatusCode.ERROR + assert span.attributes[_PREFIX + "close.code"] == 1011 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +@pytest.mark.parametrize("role", ["system", "user", "assistant"]) +async def test_input_message_summary_preserves_supported_roles(telemetry, is_async, content, role): + telemetry.instrumentor.instrument(enable_content_recording=content) + manager, _, _ = _manager(telemetry.monkeypatch, is_async) + connection = await manager.enter() if is_async else manager.enter() + await _call( + connection, + "send", + is_async, + event={ + "type": "conversation.item.create", + "item": {"type": "message", "role": role, "content": [{"type": "input_text", "text": _SECRET}]}, + }, + ) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + (message,) = json.loads(span.attributes["gen_ai.input.messages"]) + assert message["role"] == role + assert (_SECRET in span.to_json()) is content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +@pytest.mark.parametrize("boundary", ["commit", "clear", "vad"]) +@pytest.mark.parametrize("ending", ["pending", "failed", "completed"]) +async def test_audio_turn_summary_replaces_prior_transcript(telemetry, is_async, content, boundary, ending): + telemetry.instrumentor.instrument(enable_content_recording=content) + events = [ + {"type": "input_audio_buffer.committed", "item_id": "audio-first"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": "FIRST PUBLIC TURN", + }, + { + "type": "input_audio_buffer.speech_started" if boundary == "vad" else "input_audio_buffer.committed", + "item_id": "audio-second", + }, + { + "type": "conversation.item.input_audio_transcription." + + ("completed" if ending == "completed" else "failed"), + "item_id": "audio-second", + "transcript": "SECOND PUBLIC TURN", + }, + ] + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + append = {"type": "input_audio_buffer.append", "audio": "AQABAA=="} + await _call(connection, "send", is_async, event=append) + if boundary != "vad": + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + if boundary == "clear": + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.clear"}) + await _call(connection, "send", is_async, event=append) + if ending != "pending": + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + (message,) = json.loads(span.attributes["gen_ai.input.messages"]) + assert message["role"] == "user" and message["parts"][0]["type"] == "audio" + assert "FIRST PUBLIC TURN" not in span.attributes["gen_ai.input.messages"] + if content and ending == "completed": + assert message["parts"][0]["content"] == "SECOND PUBLIC TURN" + else: + assert "content" not in message["parts"][0] + assert span.attributes[_PREFIX + "input.audio.bytes"] == 8 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("latest", ["text", "tool", "audio-pending", "audio-confirmed"]) +async def test_late_audio_events_cannot_overwrite_newer_input(telemetry, is_async, latest): + telemetry.instrumentor.instrument(enable_content_recording=True) + events = [ + {"type": "input_audio_buffer.committed", "item_id": "audio-first"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": "FIRST TURN", + }, + {"type": "input_audio_buffer.speech_started", "item_id": "audio-second"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": _SECRET, + }, + {"type": "conversation.item.input_audio_transcription.failed", "item_id": "audio-first"}, + {"type": "input_audio_buffer.committed", "item_id": "audio-first"}, + ] + if latest != "audio-confirmed": + events.pop(2) + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + append = {"type": "input_audio_buffer.append", "audio": "AQABAA=="} + await _call(connection, "send", is_async, event=append) + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + if latest in ("text", "tool"): + item = ( + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "LATEST TEXT"}]} + if latest == "text" + else {"type": "function_call_output", "call_id": "call-latest", "output": "LATEST RESULT"} + ) + await _call(connection, "send", is_async, event={"type": "conversation.item.create", "item": item}) + else: + await _call(connection, "send", is_async, event=append) + if latest == "audio-confirmed": + await _call(connection, "recv", is_async) + for _ in range(3): + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + (message,) = json.loads(span.attributes["gen_ai.input.messages"]) + assert _SECRET not in span.to_json() + if latest == "text": + assert message["parts"][0]["content"] == "LATEST TEXT" + elif latest == "tool": + assert message["parts"][0]["result"] == "LATEST RESULT" + else: + assert message["parts"][0] == {"type": "audio"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +@pytest.mark.parametrize("boundary", ["client-clear", "server-cleared", "client-commit"]) +async def test_audio_summary_guard_resets_at_explicit_buffer_boundaries(telemetry, is_async, content, boundary): + telemetry.instrumentor.instrument(enable_content_recording=content) + events = [ + {"type": "input_audio_buffer.speech_started", "item_id": "audio-first", "audio_start_ms": 0}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": _SECRET, + }, + {"type": "input_audio_buffer.cleared"}, + ] + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + append = {"type": "input_audio_buffer.append", "audio": "AQABAA=="} + await _call(connection, "send", is_async, event=append) + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + if boundary == "server-cleared": + await _call(connection, "recv", is_async) + else: + await _call( + connection, + "send", + is_async, + event={"type": "input_audio_buffer.clear" if boundary == "client-clear" else "input_audio_buffer.commit"}, + ) + await _call(connection, "send", is_async, event=append) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert json.loads(span.attributes["gen_ai.input.messages"]) == [{"role": "user", "parts": [{"type": "audio"}]}] + assert span.attributes[_PREFIX + "input.audio.bytes"] == 8 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +async def test_audio_text_audio_alternation_keeps_only_latest_input(telemetry, is_async, content): + telemetry.instrumentor.instrument(enable_content_recording=content) + events = [ + {"type": "input_audio_buffer.committed", "item_id": "audio-first"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": "FIRST AUDIO", + }, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": _SECRET, + }, + {"type": "input_audio_buffer.committed", "item_id": "audio-second"}, + {"type": "input_audio_buffer.speech_started", "item_id": "audio-first", "audio_start_ms": 0}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": _SECRET, + }, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-second", + "transcript": "LATEST AUDIO", + }, + ] + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + append = {"type": "input_audio_buffer.append", "audio": "AQABAA=="} + await _call(connection, "send", is_async, event=append) + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + for _ in range(2): + await _call(connection, "recv", is_async) + await _call( + connection, + "send", + is_async, + event={ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "LATEST TEXT"}], + }, + }, + ) + await _call(connection, "recv", is_async) + state = getattr(connection, "_voice_trace") + expected = [{"role": "user", "parts": [{"type": "text", **({"content": "LATEST TEXT"} if content else {})}]}] + assert json.loads(state.span.span_instance.attributes["gen_ai.input.messages"]) == expected + await _call(connection, "send", is_async, event=append) + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + for _ in range(4): + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + part = json.loads(span.attributes["gen_ai.input.messages"])[0]["parts"][0] + assert part == {"type": "audio", **({"content": "LATEST AUDIO"} if content else {})} + assert span.attributes[_PREFIX + "input.audio.bytes"] == 8 + assert _SECRET not in span.to_json() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("content", [False, True]) +async def test_interleaved_audio_text_outputs_stay_with_their_response(telemetry, is_async, content): + telemetry.instrumentor.instrument(enable_content_recording=content) + events = [ + {"type": "response.created", "response": {"id": "response-first"}}, + {"type": "response.created", "response": {"id": "response-second"}}, + {"type": "response.output_audio.delta", "response_id": "response-first", "delta": "AQABAA=="}, + {"type": "response.output_text.delta", "response_id": "response-second", "delta": "SECOND TEXT"}, + { + "type": "response.output_audio_transcript.done", + "response_id": "response-first", + "item_id": "output-item", + "content_index": 0, + "transcript": "FIRST AUDIO", + }, + { + "type": "response.output_text.done", + "response_id": "response-second", + "item_id": "output-item", + "content_index": 1, + "text": "SECOND TEXT", + }, + {"type": "response.output_audio.delta", "response_id": "response-second", "delta": "AQABAAEAAQA="}, + {"type": "response.output_text.delta", "response_id": "response-first", "delta": "FIRST TEXT"}, + { + "type": "response.output_audio_transcript.done", + "response_id": "response-second", + "item_id": "output-item", + "content_index": 0, + "transcript": "SECOND AUDIO", + }, + { + "type": "response.output_text.done", + "response_id": "response-first", + "item_id": "output-item", + "content_index": 1, + "text": "FIRST TEXT", + }, + ] + for identifier in ("response-second", "response-first"): + events.append( + { + "type": "response.output_audio.done", + "response_id": identifier, + "item_id": "output-item", + "content_index": 0, + } + ) + events.append( + { + "type": "response.done", + "response": { + "id": identifier, + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "id": "output-item", + "content": [{"type": "output_audio"}, {"type": "output_text"}], + }, + ], + }, + } + ) + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + for _ in events: + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (session,) = _spans(telemetry, "voice_agents.realtime.session") + spans = {span.attributes["gen_ai.response.id"]: span for span in _spans(telemetry, "invoke_agent")} + assert set(spans) == {"response-first", "response-second"} + for identifier, size, prefix in (("response-first", 4, "FIRST"), ("response-second", 8, "SECOND")): + span = spans[identifier] + parts = json.loads(span.attributes["gen_ai.output.messages"])[0]["parts"] + assert parts == [ + {"type": "audio", **({"content": prefix + " AUDIO"} if content else {})}, + {"type": "text", **({"content": prefix + " TEXT"} if content else {})}, + ] + assert span.attributes[_PREFIX + "output.audio.bytes"] == size + assert span.parent.span_id == session.context.span_id + assert span.status.status_code is StatusCode.OK + assert session.attributes[_PREFIX + "output.audio.bytes"] == 12 + + +def test_audio_tracking_is_bounded_and_does_not_write_attributes_per_chunk(telemetry): + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + add_attribute = MagicMock(wraps=state.span.add_attribute) + telemetry.monkeypatch.setattr(state.span, "add_attribute", add_attribute) + for index in range(voice._MAX_PARTS * 2): + state.received({"type": "input_audio_buffer.speech_started", "item_id": "audio-" + str(index)}) + assert len(state.audio_item_ids) == voice._MAX_PARTS + assert state.input_item_id == "audio-" + str(voice._MAX_PARTS * 2 - 1) + state.received({"type": "input_audio_buffer.speech_started", "item_id": "X" * (voice._MAX_CONTENT + 1)}) + assert len(state.input_item_id) <= voice._MAX_CONTENT + writes = add_attribute.call_count + for _ in range(64): + connection.send({"type": "input_audio_buffer.append", "audio": "AQABAA=="}) + assert add_attribute.call_count == writes + connection.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("latest", ["pending", "committed", "text"]) +async def test_delayed_audio_commit_acknowledgment_does_not_claim_newer_input(telemetry, is_async, latest): + telemetry.instrumentor.instrument(enable_content_recording=True) + events = [ + {"type": "input_audio_buffer.committed", "item_id": "audio-first"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-first", + "transcript": _SECRET, + }, + {"type": "input_audio_buffer.committed", "item_id": "audio-second"}, + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-second", + "transcript": "LATEST AUDIO", + }, + ] + manager, _, _ = _manager(telemetry.monkeypatch, is_async, events=events) + connection = await manager.enter() if is_async else manager.enter() + append = {"type": "input_audio_buffer.append", "audio": "AQABAA=="} + await _call(connection, "send", is_async, event=append) + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + if latest == "text": + await _call( + connection, + "send", + is_async, + event={ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "LATEST TEXT"}], + }, + }, + ) + else: + await _call(connection, "send", is_async, event=append) + if latest == "committed": + await _call(connection, "send", is_async, event={"type": "input_audio_buffer.commit"}) + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + state = getattr(connection, "_voice_trace") + message = json.loads(state.span.span_instance.attributes["gen_ai.input.messages"])[0] + assert _SECRET not in state.span.span_instance.attributes["gen_ai.input.messages"] + assert message["parts"][0] == ( + {"type": "text", "content": "LATEST TEXT"} if latest == "text" else {"type": "audio"} + ) + if latest == "committed": + await _call(connection, "recv", is_async) + await _call(connection, "recv", is_async) + await _call(connection, "close", is_async) + (span,) = _spans(telemetry, "voice_agents.realtime.session") + if latest == "committed": + assert json.loads(span.attributes["gen_ai.input.messages"])[0]["parts"][0]["content"] == "LATEST AUDIO" + assert _SECRET not in span.to_json() + + +def test_pending_audio_commit_tracking_is_bounded_without_misattributing_old_acks(telemetry): + telemetry.instrumentor.instrument(enable_content_recording=True) + manager, _, _ = _manager(telemetry.monkeypatch, False) + connection = manager.enter() + state = getattr(connection, "_voice_trace") + count = voice._MAX_PARTS + 2 + for _ in range(count): + connection.send({"type": "input_audio_buffer.append", "audio": "AQABAA=="}) + connection.send({"type": "input_audio_buffer.commit"}) + assert len(state.pending_audio_commits) == voice._MAX_PARTS + assert state.untracked_audio_commits == 2 + for index in range(count): + state.received({"type": "input_audio_buffer.committed", "item_id": "audio-" + str(index)}) + state.received( + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-" + str(index), + "transcript": "LATEST AUDIO" if index == count - 1 else _SECRET, + } + ) + assert _SECRET not in state.span.span_instance.attributes["gen_ai.input.messages"] + assert not state.pending_audio_commits and state.untracked_audio_commits == 0 + assert len(state.audio_item_ids) == voice._MAX_PARTS + assert len(state.committed_audio_ids) == voice._MAX_PARTS + connection.close() + (span,) = _spans(telemetry, "voice_agents.realtime.session") + assert json.loads(span.attributes["gen_ai.input.messages"])[0]["parts"][0]["content"] == "LATEST AUDIO" + assert span.attributes[_PREFIX + "input.audio.bytes"] == count * 4 diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_agent_optimization_poller.py b/sdk/ai/azure-ai-projects/tests/agents/test_agent_optimization_poller.py index cb2b1338a0d7..33bef12f9791 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/test_agent_optimization_poller.py +++ b/sdk/ai/azure-ai-projects/tests/agents/test_agent_optimization_poller.py @@ -2,14 +2,91 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -"""Unit tests for sync agent optimization pollers.""" +"""Unit tests for agent optimization pollers.""" -from unittest.mock import MagicMock +import asyncio +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from azure.core.exceptions import HttpResponseError +from azure.core.pipeline import PipelineContext, PipelineResponse +from azure.core.rest import HttpRequest from azure.ai.projects.models import AgentOptimizationLROPoller +from azure.ai.projects.aio.operations._patch_agents_async import AgentsOperations as AsyncAgentsOperations from azure.ai.projects.operations._patch_agents import AgentsOperations +class _JobResponse: + def __init__(self, request, status_code, headers, payload): + self.request = request + self.status_code = status_code + self.headers = headers + self.content = json.dumps(payload).encode() + self.reason = "Test response" + + def text(self): + return self.content.decode() + + def json(self): + return json.loads(self.content) + + +class _SyncJobResponse(_JobResponse): + def read(self): + return self.content + + +class _AsyncJobResponse(_JobResponse): + async def read(self): + return self.content + + +def _job_response(status: str, *, initial: bool = False, is_async: bool = False) -> PipelineResponse: + request = HttpRequest( + "POST" if initial else "GET", + "https://example.test/agent_optimization_jobs/optimization-job", + headers={"x-ms-client-request-id": "request-test"}, + ) + payload: dict[str, Any] = {"id": "optimization-job", "status": status} + headers = ( + { + "operation-location": "https://example.test/agent_optimization_jobs/optimization-job?api-version=v1", + "location": "https://example.test/agent_optimization_jobs/optimization-job?api-version=v1", + } + if initial + else {} + ) + response_type = _AsyncJobResponse if is_async else _SyncJobResponse + response = response_type(request, 201 if initial else 200, headers, payload) + return PipelineResponse(request, response, PipelineContext(None)) + + +def _operation_for_cancellation(*, is_async: bool = False): + operation_type = AsyncAgentsOperations if is_async else AgentsOperations + operation = operation_type.__new__(operation_type) + operation._client = MagicMock() # pylint: disable=protected-access + operation._config = MagicMock(polling_interval=0) # pylint: disable=protected-access + operation._serialize = MagicMock() # pylint: disable=protected-access + operation._serialize.url.return_value = "https://example.test" # pylint: disable=protected-access + operation._deserialize = MagicMock() # pylint: disable=protected-access + initial = _job_response("queued", initial=True, is_async=is_async) + responses = [ + _job_response("in_progress", is_async=is_async), + _job_response("cancelled", is_async=is_async), + ] + if is_async: + operation._create_optimization_job_initial = AsyncMock(return_value=initial) # pylint: disable=protected-access + operation._client.send_request = AsyncMock(side_effect=responses) # pylint: disable=protected-access + operation._client._pipeline._transport.sleep = AsyncMock() # pylint: disable=protected-access + else: + operation._create_optimization_job_initial = MagicMock(return_value=initial) # pylint: disable=protected-access + operation._client.send_request = MagicMock(side_effect=responses) # pylint: disable=protected-access + return operation + + def test_begin_create_optimization_job_exposes_job_id(): """The sync create operation exposes its job ID without SDK polling.""" operation = AgentsOperations.__new__(AgentsOperations) @@ -29,3 +106,28 @@ def test_begin_create_optimization_job_exposes_job_id(): assert isinstance(poller, AgentOptimizationLROPoller) assert poller.details["job_id"] == "optimization-job-sync" + + +def test_optimization_poller_stops_after_cancellation(): + operation = _operation_for_cancellation() + + poller = operation.begin_create_optimization_job(job={}) + + with pytest.raises(HttpResponseError): + poller.result(timeout=5) + assert poller.done() + assert poller.status() == "cancelled" + assert operation._client.send_request.call_count == 2 # pylint: disable=protected-access + + +@pytest.mark.asyncio +async def test_async_optimization_poller_stops_after_cancellation(): + operation = _operation_for_cancellation(is_async=True) + + poller = await operation.begin_create_optimization_job(job={}) + + with pytest.raises(HttpResponseError): + await asyncio.wait_for(poller.result(), timeout=5) + assert poller.polling_method().finished() + assert poller.status() == "cancelled" + assert operation._client.send_request.call_count == 2 # pylint: disable=protected-access diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_realtime_transport.py b/sdk/ai/azure-ai-projects/tests/agents/test_realtime_transport.py new file mode 100644 index 000000000000..58a8f3b16783 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/agents/test_realtime_transport.py @@ -0,0 +1,205 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Offline tests for typed realtime transport selection in both Python clients.""" + +import inspect +from types import SimpleNamespace +from typing import Any, Optional, Union +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs, urlsplit + +import pytest +from azure.core.credentials import AccessToken +from azure.ai.projects import AIProjectClient +from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient +from azure.ai.projects._realtime import BetaRealtime, BetaRealtimeConnectionManager +from azure.ai.projects.aio._realtime import AsyncBetaRealtime, AsyncBetaRealtimeConnectionManager +from azure.ai.projects.models import VoiceAgentTransport + +_ENDPOINT = "https://fake-account.services.ai.azure.com/api/projects/fake-project" +_PUBLIC_METHODS = [ + BetaRealtime.connect, + AsyncBetaRealtime.connect, + BetaRealtimeConnectionManager.__init__, + AsyncBetaRealtimeConnectionManager.__init__, +] + + +def _make_realtime(is_async: bool) -> tuple[Union[BetaRealtime, AsyncBetaRealtime], MagicMock]: + credential = MagicMock() + token = AccessToken("fake-token", 9_999_999_999) + credential.get_token = AsyncMock(return_value=token) if is_async else MagicMock(return_value=token) + client = SimpleNamespace( + _config=SimpleNamespace( + endpoint=_ENDPOINT, + credential=credential, + credential_scopes=["https://ai.azure.com/.default"], + api_version="v1", + ) + ) + return (AsyncBetaRealtime(client) if is_async else BetaRealtime(client)), credential + + +async def _capture_handshake( + manager: Union[BetaRealtimeConnectionManager, AsyncBetaRealtimeConnectionManager], +) -> tuple[str, dict[str, Any]]: + if isinstance(manager, AsyncBetaRealtimeConnectionManager): + connection = MagicMock(close=AsyncMock()) + session = MagicMock(ws_connect=AsyncMock(return_value=connection), close=AsyncMock()) + with patch("aiohttp.ClientSession", return_value=session): + async with manager: + pass + call = session.ws_connect.call_args + session.close.assert_awaited_once() + connection.close.assert_awaited_once() + else: + connection = MagicMock() + with patch("websockets.sync.client.connect", return_value=connection) as connect: + with manager: + pass + call = connect.call_args + connection.close.assert_called_once() + + assert "transport" not in call.kwargs + return call.args[0], call.kwargs + + +def _handshake_query(url: str, kwargs: dict[str, Any]) -> dict[str, list[str]]: + query = parse_qs(urlsplit(url).query, keep_blank_values=True) + for name, value in (kwargs.get("params") or {}).items(): + query.setdefault(name, []).append(value) + return query + + +@pytest.mark.parametrize("method", _PUBLIC_METHODS) +def test_transport_is_optional_and_keyword_only(method: Any) -> None: + parameter = inspect.signature(method).parameters["transport"] + + assert parameter.kind == inspect.Parameter.KEYWORD_ONLY + assert parameter.default is None + assert "VoiceAgentTransport" in parameter.annotation + + +@pytest.mark.parametrize("method", _PUBLIC_METHODS) +def test_extra_query_retains_original_string_mapping_contract(method: Any) -> None: + parameter = inspect.signature(method).parameters["extra_query"] + + assert parameter.annotation == "Optional[Mapping[str, str]]" + assert parameter.default is None + + +def test_generated_transport_enum_is_public_with_expected_wire_values() -> None: + assert VoiceAgentTransport.WEBSOCKET.value == "websocket" + assert VoiceAgentTransport.WEBRTC.value == "webrtc" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize( + "transport", + [VoiceAgentTransport.WEBSOCKET, VoiceAgentTransport.WEBRTC, "websocket", "webrtc", "future-transport"], +) +async def test_transport_is_encoded_internally_without_socket_keyword( + is_async: bool, transport: Union[str, VoiceAgentTransport] +) -> None: + realtime, _ = _make_realtime(is_async) + manager = realtime.connect(agent_name="agent-test", transport=transport) + url, kwargs = await _capture_handshake(manager) + query = _handshake_query(url, kwargs) + expected = transport.value if isinstance(transport, VoiceAgentTransport) else transport + + assert query["transport"] == [expected] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("explicit_none", [False, True], ids=["omitted", "none"]) +async def test_default_transport_does_not_add_a_query_parameter(is_async: bool, explicit_none: bool) -> None: + realtime, _ = _make_realtime(is_async) + options = {"transport": None} if explicit_none else {} + manager = realtime.connect(agent_name="agent-test", **options) + url, kwargs = await _capture_handshake(manager) + + assert "transport" not in _handshake_query(url, kwargs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("transport", [None, VoiceAgentTransport.WEBRTC], ids=["legacy-query", "typed-option"]) +async def test_transport_selection_preserves_other_query_values_and_override_precedence( + is_async: bool, transport: Optional[VoiceAgentTransport] +) -> None: + realtime, _ = _make_realtime(is_async) + extra_query = {"transport": "websocket", "custom": "space + /?&=#"} + connection_url = ( + "wss://fake-account.services.ai.azure.com/custom" + "?transport=old&%74ransport=duplicate&keep=one&keep=two&empty=&path=folder%2F" + ) + manager = realtime.connect( + agent_name="agent-test", + connection_url=connection_url, + transport=transport, + extra_query=extra_query, + ) + url, kwargs = await _capture_handshake(manager) + query = _handshake_query(url, kwargs) + + assert query["keep"] == ["one", "two"] + assert query["empty"] == [""] + assert query["path"] == ["folder/"] + assert query["custom"] == ["space + /?&=#"] + assert "path=folder%2F" in url + if transport is not None: + assert query["transport"] == ["webrtc"] + else: + assert query["transport"] == ["old", "duplicate", "websocket"] + assert extra_query == {"transport": "websocket", "custom": "space + /?&=#"} + + +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("transport", [False, 1, {}, object()]) +def test_invalid_transport_type_fails_before_authentication_or_socket_creation(is_async: bool, transport: Any) -> None: + realtime, credential = _make_realtime(is_async) + with patch("aiohttp.ClientSession") as session, patch("websockets.sync.client.connect") as connect: + with pytest.raises(TypeError, match="transport must be a string or VoiceAgentTransport"): + realtime.connect(agent_name="agent-test", transport=transport) + + credential.get_token.assert_not_called() + session.assert_not_called() + connect.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +async def test_transport_selection_is_connection_scoped(is_async: bool) -> None: + realtime, _ = _make_realtime(is_async) + first = realtime.connect(agent_name="agent-test", transport=VoiceAgentTransport.WEBRTC) + second = realtime.connect(agent_name="agent-test") + first_url, first_kwargs = await _capture_handshake(first) + second_url, second_kwargs = await _capture_handshake(second) + + assert _handshake_query(first_url, first_kwargs)["transport"] == ["webrtc"] + assert "transport" not in _handshake_query(second_url, second_kwargs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +async def test_public_project_client_exposes_typed_transport_selection(is_async: bool) -> None: + _, credential = _make_realtime(is_async) + manager: Union[BetaRealtimeConnectionManager, AsyncBetaRealtimeConnectionManager] + if is_async: + async with AsyncAIProjectClient(endpoint=_ENDPOINT, credential=credential, allow_preview=True) as async_client: + manager = async_client.beta.voice_agents.realtime.connect( + agent_name="agent-test", transport=VoiceAgentTransport.WEBRTC + ) + url, kwargs = await _capture_handshake(manager) + else: + with AIProjectClient(endpoint=_ENDPOINT, credential=credential, allow_preview=True) as client: + manager = client.beta.voice_agents.realtime.connect( + agent_name="agent-test", transport=VoiceAgentTransport.WEBRTC + ) + url, kwargs = await _capture_handshake(manager) + + assert _handshake_query(url, kwargs)["transport"] == ["webrtc"] diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations.py b/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations.py index 3c1ecc499936..c20741a54e11 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations.py +++ b/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations.py @@ -186,6 +186,7 @@ def test_read_conversation(self, **kwargs): # pylint: disable=too-many-locals responses = list(conversations.list_responses(_AGENT_NAME, conversation_id)) assert len(responses) >= 1 first_response = responses[0] + assert first_response.id, "Expected a persisted response to carry an ID" response_detail = conversations.get_response(_AGENT_NAME, conversation_id, first_response.id) assert response_detail.id == first_response.id diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations_async.py b/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations_async.py index d843a1690994..a9b62024f442 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations_async.py +++ b/sdk/ai/azure-ai-projects/tests/agents/test_voice_agent_conversations_async.py @@ -168,6 +168,7 @@ async def test_read_conversation_async(self, **kwargs): # pylint: disable=too-m responses = [r async for r in conversations.list_responses(_AGENT_NAME, conversation_id)] assert len(responses) >= 1 first_response = responses[0] + assert first_response.id, "Expected a persisted response to carry an ID" response_detail = await conversations.get_response(_AGENT_NAME, conversation_id, first_response.id) assert response_detail.id == first_response.id diff --git a/sdk/ai/azure-ai-projects/tests/conftest.py b/sdk/ai/azure-ai-projects/tests/conftest.py index d3eaf64184e4..4e75e5f76439 100644 --- a/sdk/ai/azure-ai-projects/tests/conftest.py +++ b/sdk/ai/azure-ai-projects/tests/conftest.py @@ -11,8 +11,10 @@ mimetypes.add_type("text/csv", ".csv") mimetypes.add_type("text/markdown", ".md") +import json import os import re +import sys import pytest from dotenv import find_dotenv, load_dotenv from devtools_testutils import ( @@ -24,12 +26,72 @@ add_body_key_sanitizer, add_remove_header_sanitizer, ) +from devtools_testutils.proxy_startup import test_proxy from agent_insights.sanitizers import add_agent_insights_sanitizers +from test_base import servicePreparer -if not load_dotenv(find_dotenv(), override=True): +_SERVICE_PREPARER_DEFAULTS = servicePreparer.keywords or {} + + +def _get_ci_environment_options(): + names = json.loads(os.environ.get("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", "[]")) + if not isinstance(names, list) or any( + not isinstance(name, str) + or not re.fullmatch(r"[A-Z][A-Z0-9_]*", name) + or {"PAT", "SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "CREDENTIALS"} & set(name.split("_")) + for name in names + ): + raise ValueError("CI environment option names must be a list of non-secret uppercase bindings.") + return {name.lower(): name for name in names} + + +_CLI_ENV_OPTIONS = { + **_get_ci_environment_options(), + **{option_name: option_name.upper() for option_name in _SERVICE_PREPARER_DEFAULTS}, + "azure_test_run_live": "AZURE_TEST_RUN_LIVE", + "azure_skip_live_recording": "AZURE_SKIP_LIVE_RECORDING", +} + + +def _has_cli_env_options() -> bool: + option_names = {f"--{option_name}" for option_name in _CLI_ENV_OPTIONS} | { + f"--{option_name.replace('_', '-')}" for option_name in _CLI_ENV_OPTIONS + } + return any(argument.split("=", 1)[0] in option_names for argument in sys.argv[1:]) + + +_CLI_ENV_MODE = _has_cli_env_options() +if _CLI_ENV_MODE: + os.environ["PYTHON_DOTENV_DISABLED"] = "true" +elif not load_dotenv(find_dotenv(), override=True): print("Did not find a .env file. Using default environment variable values for tests.") +def pytest_addoption(parser): + group = parser.getgroup("azure-ai-projects environment") + for option_name in _CLI_ENV_OPTIONS: + group.addoption( + f"--{option_name}", + f"--{option_name.replace('_', '-')}", + dest=option_name, + help=f"Set the {option_name.upper()} environment variable.", + ) + + +def pytest_configure(config): + if not _CLI_ENV_MODE: + return + + for option_name, environment_variable in _CLI_ENV_OPTIONS.items(): + value = config.getoption(option_name) + if value is None and environment_variable == "LLM_VALIDATION_PROJECT_ENDPOINT": + value = os.environ.get(environment_variable) + if value is None: + value = _SERVICE_PREPARER_DEFAULTS.get(option_name) + if value is not None: + os.environ[environment_variable] = value + + def pytest_collection_modifyitems(items): if os.environ.get("AZURE_TEST_RUN_LIVE") == "true": return @@ -146,6 +208,13 @@ def sanitize_url_paths(): # Sanitize eval dataset names with timestamps (e.g., eval-data-2026-01-19_040648_UTC) add_general_regex_sanitizer(regex=r"eval-data-\d{4}-\d{2}-\d{2}_\d{6}_UTC", value="eval-data-sanitized-timestamp") + # Preserve distinct job/dataset names while stabilizing their timestamp/UUID suffixes. + add_general_regex_sanitizer( + regex=r"simulation-seed(?:-sample)?-(\d{12}-[a-f0-9]{4})", + value="sanitized-run-id", + group_for_replace="1", + ) + # Sanitize Unix timestamps in eval names (from sample_redteam_evaluations.py) # Pattern 1: "Red Team Agent Safety Evaluation -" add_general_regex_sanitizer(regex=r"Evaluation -\d{10}", value="Evaluation -SANITIZED-TS") diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py index 1a843d8acc77..884e69e74542 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py @@ -47,7 +47,7 @@ "skills": "Skills=V1Preview", "voice_agents": "VoiceAgents=V1Preview", "datasets": "DataGenerationJobs=V1Preview", - "agents": "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,VoiceAgents=V1Preview,DigitalWorker=V1Preview,GitHubCopilot=V1Preview,Skills=V1Preview,ModelRouterControls=V1Preview", + "agents": "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,VoiceAgents=V1Preview,DigitalWorker=V1Preview,GitHubCopilot=V1Preview,Skills=V1Preview,MicrosoftCopilot=V1Preview,ModelRouterControls=V1Preview", } # Methods on .beta sub-clients that are NOT simple one-HTTP-call wrappers and @@ -97,7 +97,7 @@ # The test id is derived automatically from method_name. pytest.param( "agents.create_version", - "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,VoiceAgents=V1Preview,DigitalWorker=V1Preview,GitHubCopilot=V1Preview,Skills=V1Preview,ModelRouterControls=V1Preview", + "WorkflowAgents=V1Preview,ExternalAgents=V1Preview,DraftAgents=V1Preview,VoiceAgents=V1Preview,DigitalWorker=V1Preview,GitHubCopilot=V1Preview,Skills=V1Preview,MicrosoftCopilot=V1Preview,ModelRouterControls=V1Preview", ), pytest.param( "evaluation_rules.create_or_update", diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs.py index d9986ed24b1c..33ca93a2df3d 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs.py @@ -128,7 +128,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__() polling_kwargs.update(kwargs) - monkeypatch.setattr(_patch_datasets, "LROBasePolling", _RecordingPolling) + monkeypatch.setattr(_patch_datasets, "AIProjectsLROBasePolling", _RecordingPolling) operation = DatasetsOperations.__new__(DatasetsOperations) operation._client = MagicMock() # pylint: disable=protected-access diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs_async.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs_async.py index c51efa276c34..da9a1ae20f9e 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs_async.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_data_generation_jobs_async.py @@ -125,7 +125,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__() polling_kwargs.update(kwargs) - monkeypatch.setattr(_patch_datasets_async, "AsyncLROBasePolling", _RecordingPolling) + monkeypatch.setattr(_patch_datasets_async, "AsyncAIProjectsLROBasePolling", _RecordingPolling) operation = DatasetsOperations.__new__(DatasetsOperations) operation._client = MagicMock() # pylint: disable=protected-access diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-ResourceProvisioning.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-ResourceProvisioning.ps1 new file mode 100644 index 000000000000..9ba706b740aa --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-ResourceProvisioning.ps1 @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +<# +.SYNOPSIS +Validates infrastructure plans and provisions Azure resources for any SDK language. + +.DESCRIPTION +Admission mode resolves shared identities and checks batch capacity +before provisioning workers start. Provision mode requires -ConfirmProvisioning, creates +a fresh run-owned resource group, and deploys the complete resource bundle: Foundry, +Storage, Cosmos DB, Search, model and MCP resources, and a Function App with its published +queue handler. The sample query selects tests, not resources or prerequisites. + +Uses existing shared identities without changing their bootstrap grants. Saves deployment +status, non-secret environment bindings, and resource inventories for subsequent test and +cleanup steps. This script does not execute samples or delete the deployed resources. +#> + +[CmdletBinding(DefaultParameterSetName = 'Provision')] +param( + [Parameter(Mandatory, ParameterSetName = 'Provision')] [string] $PlanPath, + [Parameter(Mandatory, ParameterSetName = 'Provision')] [string] $StatePath, + [Parameter(Mandatory, ParameterSetName = 'Provision')] [ValidateSet('q1', 'q2', 'q3', 'q4')] [string] $QueueName, + [Parameter(ParameterSetName = 'Provision')] [switch] $ConfirmProvisioning, + [Parameter(Mandatory, ParameterSetName = 'Admission')] [string] $BatchPlanDirectory, + [Parameter(ParameterSetName = 'Admission')] [string] $ResolvedPlanDirectory +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'SampleCiHelpers.ps1') +if ($PSCmdlet.ParameterSetName -eq 'Admission') { + $files = @(Get-ChildItem -LiteralPath $BatchPlanDirectory -Filter 'deployment-plan.json' -Recurse -File) + if ($files.Count -lt 1 -or $files.Count -gt 4) { throw 'Expected one to four fresh queue plans.' } + $plans = @(foreach ($file in $files) { + $queue = Split-Path -Leaf (Split-Path -Parent $file.FullName) + $item = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable + Assert-SampleRunPlan -Plan $item -QueueName $queue + $item + }) + $null = Set-SampleSubscriptionContext -Plan $plans[0] + # Resolve each immutable offline name intent before any capacity check/write. + # No unresolved artifact is ever used by provisioning workers. + $resolvedPlans = @() + foreach ($item in $plans) { + $resolvedPlans += $(if ($item.ContainsKey('identityResolution') -and $item.identityResolution -ceq 'Pending') { + Resolve-SampleIdentitySelection -Plan $item + } else { $item }) + } + $plans = $resolvedPlans + $null = Get-SampleSharedIdentities -Plan $plans[0] + Assert-SampleBatchCapacity -Plans $plans + if ($ResolvedPlanDirectory) { + # Workers consume these resolved plans, not a second identity lookup that could select different identities. + if (Test-Path -LiteralPath $ResolvedPlanDirectory) { throw 'Resolved plan output already exists; refusing to overwrite an earlier admission.' } + foreach ($item in $plans) { + $directory = Join-Path $ResolvedPlanDirectory $item.queueName + $null = New-Item -ItemType Directory -Path $directory -Force + $item | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $directory 'deployment-plan.json') -Encoding utf8 + } + } + return +} +if (-not $ConfirmProvisioning) { throw 'Provisioning is disabled by default. Use -ConfirmProvisioning only for an explicitly requested live run.' } +$plan = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json -AsHashtable +Assert-SampleRunPlan -Plan $plan -QueueName $QueueName +if ($plan.ContainsKey('identityResolution') -and $plan.identityResolution -cne 'Resolved') { throw 'Only an admitted, resolved identity plan may provision resources.' } +# A retry must get fresh ownership and state; adopting an earlier attempt could make cleanup target its resources. +if (Test-Path -LiteralPath $StatePath) { throw 'Deployment state already exists. Do not adopt or overwrite a previous attempt.' } +Assert-SampleMcpPat +if ([string]::IsNullOrWhiteSpace($env:PROJ_CONN_OPEN_API_KEY) -or $env:PROJ_CONN_OPEN_API_KEY -match '^\$\(.+\)$') { + throw 'Set PROJ_CONN_OPEN_API_KEY as an ADO secret containing the OpenAPI service key; no resources were changed.' +} + +$runnerIdentity = Set-SampleSubscriptionContext -Plan $plan +$subscription = Get-SampleSubscriptionId +$exists = Invoke-SampleAzure -Arguments @('group', 'exists', '--name', $plan.resourceGroup, '--subscription', $subscription) +if ($exists) { throw 'The generated resource group already exists. No existing group will be reused, retagged, or deleted.' } + +Assert-SampleRegionAvailability -SubscriptionId $subscription -Location $plan.location +$identities = Get-SampleSharedIdentities -Plan $plan + +# Recheck live availability after batch admission because another deployment may have consumed capacity. +$catalog = @(Invoke-SampleAzure -Arguments @('cognitiveservices', 'model', 'list', '--location', $plan.location, '--subscription', $subscription)) +$sampleModel = Resolve-SampleModel -Catalog $catalog -Name $plan.sampleModel.name -Version $plan.sampleModel.version ` + -Sku $plan.sampleModel.sku -Capacity $plan.sampleModel.capacity +$usages = @(Invoke-SampleAzure -Arguments @('cognitiveservices', 'usage', 'list', '--location', $plan.location, '--subscription', $subscription)) +Assert-SampleAccountQuota -Usages $usages -Location $plan.location +$null = Get-SampleQuotaAvailable -Usages $usages -UsageName $sampleModel.usageName -RequiredCapacity $sampleModel.capacity ` + -Description "model deployment quota for '$($sampleModel.name)'" +Write-Host ('Model preflight: {0} {1}, {2}, capacity {3}.' -f $sampleModel.name, $sampleModel.version, $sampleModel.sku, $sampleModel.capacity) + +$groupDeploymentName = 'sample-ci-rg-' + $plan.ownerToken +$groupArguments = @('--name', $groupDeploymentName, '--location', $plan.location, '--subscription', $subscription, + '--template-file', (Join-Path $PSScriptRoot 'infra/resource-group.bicep'), '--parameters', + "location=$($plan.location)", "resourceGroupName=$($plan.resourceGroup)", + ('runId={0}-{1}-{2}-{3}' -f $plan.definitionId, $plan.buildId, $plan.queueName, $plan.jobAttempt), + "queueName=$($plan.queueName)", + "ownerToken=$($plan.ownerToken)", "expiresUtc=$($plan.expiresUtc)") +Write-Host 'Validating Bicep resource-group deployment before any resource changes.' +$null = Invoke-SampleAzure -Arguments (@('deployment', 'sub', 'validate') + $groupArguments) + +$bundleTemplatePath = Join-Path $PSScriptRoot 'infra/main.bicep' +# Persist ownership and the initial step before any resource creation so partial failures remain discoverable for cleanup. +$state = @{ + schemaVersion = 1 + plan = $plan + subscriptionId = $subscription + tenantId = $runnerIdentity.tenantId + resourceGroupDeployment = $groupDeploymentName + status = 'Provisioning' + provisioningStep = 'ResourceGroup' +} +$stateDirectory = Split-Path -Parent $StatePath +$null = New-Item -ItemType Directory -Path $stateDirectory -Force +$state | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath $StatePath -Encoding utf8 +'[]' | Set-Content -LiteralPath (Join-Path $stateDirectory 'inventory-before.json') -Encoding utf8 + +try { + $null = Invoke-SampleAzure -Arguments (@('deployment', 'sub', 'create') + $groupArguments) + $group = Invoke-SampleAzure -Arguments @('group', 'show', '--name', $plan.resourceGroup, '--subscription', $subscription) + Assert-SampleResourceGroupOwnership -Plan $plan -ResourceGroup $group -SubscriptionId $subscription + + # Bicep owns resource ordering and module wiring; new resources belong in the root template, not this script. + $state['provisioningStep'] = 'ResourceBundle' + Write-Host 'Deploying the complete shared-identity resource bundle (capability hosts can take 10-20 minutes).' + $modelParameters = @{ name = $sampleModel.name; version = $sampleModel.version; sku = $sampleModel.sku; capacity = $sampleModel.capacity } + $bundleArguments = @('deployment', 'group', 'create', '--name', 'resource-bundle', + '--resource-group', $plan.resourceGroup, '--subscription', $subscription, + '--template-file', $bundleTemplatePath, + '--parameters', (Join-Path $PSScriptRoot 'infra/sample-resources.bicepparam'), + "location=$($plan.location)", "accountPrefix=$($plan.accountPrefix)", + "deploymentTimestamp=$($plan.deploymentTimestamp)", "ownerToken=$($plan.ownerToken)", + "agentName=$($plan.agentName)", ('sampleModel=' + ($modelParameters | ConvertTo-Json -Compress)), + "sharedProjectIdentityResourceId=$($identities.project.resourceId)", + "sharedAccountIdentityResourceId=$($identities.account.resourceId)", + "sharedSearchIdentityResourceId=$($identities.search.resourceId)", + "sharedFunctionIdentityResourceId=$($identities.function.resourceId)", + "sharedProjectPrincipalId=$($identities.project.principalId)") + $bundle = Invoke-SampleAzure -Arguments $bundleArguments -Sensitive + if ($bundle.properties.provisioningState -ne 'Succeeded') { throw 'Resource bundle Bicep deployment did not succeed.' } + $state['environment'] = $bundle.properties.outputs.environment.value + Assert-SampleEnvironment -Environment $state.environment -Names @(Get-SampleBicepEnvironmentNames -BicepPath $bundleTemplatePath) + + # Publishing code is an imperative action, separate from adding declarative infrastructure. + # It remains unconditional and uses the same Function host for all SDK languages. + $state['provisioningStep'] = 'FunctionCode' + Publish-SampleFunctionApp -Plan $plan -FunctionAppName ([string] $state.environment.FUNCTION_APP_NAME) + Write-Host 'Function code published and queue_trigger indexed successfully.' + $state.environment['AZURE_SUBSCRIPTION_ID'] = $subscription + $state.environment['AZURE_TENANT_ID'] = $runnerIdentity.tenantId + $state.environment['AZURE_RESOURCE_GROUP'] = $plan.resourceGroup + Assert-SampleEnvironment -Environment $state.environment -Names @($plan.environmentBindings) + # Do not expose a Ready state until publication and trigger indexing have both succeeded. + $state['status'] = 'Ready' + $state['provisioningStep'] = 'Complete' +} catch { + $state['status'] = 'Failed' + Write-Host ('Provisioning failed during step: {0}.' -f $state.provisioningStep) + throw +} finally { + # Preserve failure state and partial inventory too; downstream cleanup must not depend on a successful deployment. + $state | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath $StatePath -Encoding utf8 + try { + Save-SampleInventory -State $state -OutputPath (Join-Path $stateDirectory 'inventory-after.json') + } catch { + Write-Warning 'Resource inventory could not be captured; cleanup must still verify the exact ownership tags.' + } +} +Write-Host 'Bicep provisioning completed. Only non-secret endpoints and resource IDs were saved for the test step.' diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-SampleProvisioning.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-SampleProvisioning.ps1 new file mode 100644 index 000000000000..f4b73e4f2041 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-SampleProvisioning.ps1 @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +<# +.SYNOPSIS +Python sample CI adapter for the language-neutral resource provisioner. +.DESCRIPTION +Checks the pytest-node partition before batch admission, then delegates Azure operations +to Invoke-ResourceProvisioning.ps1. Python sample selection never changes the resource +bundle. Other SDK languages call the shared provisioner directly with infrastructure plans. +#> +[CmdletBinding(DefaultParameterSetName = 'Provision')] +param( + [Parameter(Mandatory, ParameterSetName = 'Provision')] [string] $PlanPath, + [Parameter(Mandatory, ParameterSetName = 'Provision')] [string] $StatePath, + [Parameter(Mandatory, ParameterSetName = 'Provision')] [ValidateSet('q1', 'q2', 'q3', 'q4')] [string] $QueueName, + [Parameter(ParameterSetName = 'Provision')] [switch] $ConfirmProvisioning, + [Parameter(Mandatory, ParameterSetName = 'Admission')] [string] $BatchPlanDirectory, + [Parameter(ParameterSetName = 'Admission')] [string] $ResolvedPlanDirectory +) + +$ErrorActionPreference = 'Stop' +if ($PSCmdlet.ParameterSetName -eq 'Admission') { + . (Join-Path $PSScriptRoot 'PythonSampleCiHelpers.ps1') + $plans = @(Get-ChildItem -LiteralPath $BatchPlanDirectory -Filter 'deployment-plan.json' -Recurse -File | + ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json -AsHashtable }) + if ($plans.Count -lt 1 -or $plans.Count -gt 4) { throw 'Expected one to four fresh queue plans.' } + Assert-PythonSamplePartition -Plans $plans +} +& (Join-Path $PSScriptRoot 'Invoke-ResourceProvisioning.ps1') @PSBoundParameters diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/New-ResourceDeploymentPlan.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/New-ResourceDeploymentPlan.ps1 new file mode 100644 index 000000000000..3645b7c8bfd5 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/New-ResourceDeploymentPlan.ps1 @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +<# +.SYNOPSIS +Creates an offline infrastructure plan usable by any SDK language. +.DESCRIPTION +Requires explicit target subscription and tenant environment variables. Generates fresh +ownership metadata and discovers non-secret bindings from infra/main.bicep. No test +manifest, SDK package, Python runtime, Azure login, or service call is required. +Admission resolves existing shared identities before a worker can deploy this plan. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $OutputDirectory, + [Parameter(Mandatory)] [ValidateSet('q1', 'q2', 'q3', 'q4')] [string] $QueueName, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $IdentityResourceGroup, + [ValidatePattern('^[A-Za-z0-9._-]+$')] [string] $ModelName = 'gpt-5.2', + [ValidatePattern('^[A-Za-z0-9._-]+$')] [string] $ModelVersion = '2025-12-11', + [ValidateSet('GlobalStandard', 'Standard')] [string] $ModelSku = 'GlobalStandard', + [ValidateRange(1, 1000)] [int] $ModelCapacity = 10, + [ValidatePattern('^\d+$')] [string] $DefinitionId = $env:SYSTEM_DEFINITIONID, + [ValidatePattern('^\d+$')] [string] $BuildId = $env:BUILD_BUILDID, + [ValidatePattern('^\d+$')] [string] $JobAttempt = $env:SYSTEM_JOBATTEMPT +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'SampleCiHelpers.ps1') +$subscription = Get-SampleSubscriptionId +$tenant = Get-SampleTenantId +$identityGroup = Get-SampleIdentityResourceGroup -Name $IdentityResourceGroup +$ownerToken = [guid]::NewGuid().ToString('N') +$plan = @{ + schemaVersion = 1 + definitionId = $DefinitionId + buildId = $BuildId + jobAttempt = $JobAttempt + queueName = $QueueName + ownerToken = $ownerToken + subscriptionId = $subscription + tenantId = $tenant + resourceGroup = 'azsdk-samples-{0}-{1}-{2}-{3}-{4}' -f $DefinitionId, $BuildId, $QueueName, $JobAttempt, $ownerToken.Substring(0, 8) + identityResourceGroup = $identityGroup + identityResolution = 'Pending' + sharedIdentities = @{} + location = '' + accountPrefix = 'sdk' + $ownerToken.Substring(0, 6) + agentName = 'sample-' + $ownerToken.Substring(0, 12) + deploymentTimestamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') + expiresUtc = [DateTime]::UtcNow.AddDays(1).ToString('o') + sampleModel = @{ name = $ModelName; version = $ModelVersion; sku = $ModelSku; capacity = $ModelCapacity } + environmentBindings = @(Get-SampleEnvironmentNames) +} +Assert-SampleRunPlan -Plan $plan -QueueName $QueueName +$null = New-Item -ItemType Directory -Path $OutputDirectory -Force +$plan | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'deployment-plan.json') -Encoding utf8 +Write-Host ('DRY RUN: planned a fresh resource group {0}; no Azure resources or grants were changed.' -f $plan.resourceGroup) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/New-SampleDeploymentPlan.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/New-SampleDeploymentPlan.ps1 new file mode 100644 index 000000000000..06a576ded664 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/New-SampleDeploymentPlan.ps1 @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $SelectionManifest, + [Parameter(Mandatory)] [string] $OutputDirectory, + [Parameter(Mandatory)] [ValidateSet('q1', 'q2', 'q3', 'q4')] [string] $QueueName, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $IdentityResourceGroup, + [ValidatePattern('^[A-Za-z0-9._-]+$')] [string] $ModelName = 'gpt-5.2', + [ValidatePattern('^[A-Za-z0-9._-]+$')] [string] $ModelVersion = '2025-12-11', + [ValidateSet('GlobalStandard', 'Standard')] [string] $ModelSku = 'GlobalStandard', + [ValidateRange(1, 1000)] [int] $ModelCapacity = 10, + [ValidatePattern('^\d+$')] [string] $DefinitionId = $env:SYSTEM_DEFINITIONID, + [ValidatePattern('^\d+$')] [string] $BuildId = $env:BUILD_BUILDID, + [ValidatePattern('^\d+$')] [string] $JobAttempt = $env:SYSTEM_JOBATTEMPT, + [string] $PackageRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'SampleCiHelpers.ps1') +. (Join-Path $PSScriptRoot 'PythonSampleCiHelpers.ps1') +$manifest = Read-SampleManifest -Path $SelectionManifest -PackageRoot $PackageRoot +$scanner = Join-Path $PackageRoot '.github/skills/azure-ai-projects-record-sample/scripts/GetSampleRequiredEnvironmentVariables.ps1' +$required = @() +$runnable = @() +$rejected = @() +# Bicep outputs plus values the provisioning script adds; the validation endpoint is a pipeline variable and never enters state. +$stateBindings = @(Get-SampleEnvironmentNames) +$provided = @($stateBindings + 'LLM_VALIDATION_PROJECT_ENDPOINT') +# Validate test compatibility only; selection never changes the resource bundle or its bindings. +foreach ($test in @($manifest.tests)) { + $names = @(& $scanner -SamplePath @($test.sample_path) -PackageRoot $PackageRoot) + $unsupported = @($names | Where-Object { $_ -notin $provided }) + if ($unsupported.Count -gt 0) { + $rejected += @{ node_id = $test.node_id; sample_path = $test.sample_path; unsupported = $unsupported } + } else { + $runnable += $test + $required += $names + } +} +$required = @($required | Sort-Object -Unique) + +$null = New-Item -ItemType Directory -Path $OutputDirectory -Force +if ($rejected.Count -gt 0) { + # Only these samples fail; the rest of the selection still runs. + ConvertTo-Json -InputObject $rejected -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'rejected-tests.json') -Encoding utf8 + foreach ($item in $rejected) { + Write-Host ('REJECTED {0}: requires unsupported environment variable(s) {1}. No Azure changes were made.' -f $item.node_id, ($item.unsupported -join ', ')) + } +} +if ($runnable.Count -eq 0) { + Write-Host ('Queue {0} has no runnable sample; no plan was written.' -f $QueueName) + return +} + +# The Python adapter adds test metadata; the shared planner has no knowledge of pytest or sample paths. +& (Join-Path $PSScriptRoot 'New-ResourceDeploymentPlan.ps1') -OutputDirectory $OutputDirectory -QueueName $QueueName ` + -IdentityResourceGroup $IdentityResourceGroup -ModelName $ModelName -ModelVersion $ModelVersion ` + -ModelSku $ModelSku -ModelCapacity $ModelCapacity -DefinitionId $DefinitionId -BuildId $BuildId -JobAttempt $JobAttempt +$plan = Get-Content -LiteralPath (Join-Path $OutputDirectory 'deployment-plan.json') -Raw | ConvertFrom-Json -AsHashtable +$plan['requiredEnvironment'] = $required +$plan['selectedTests'] = @($runnable) +Assert-SampleRunPlan -Plan $plan -QueueName $QueueName +$plan | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'deployment-plan.json') -Encoding utf8 + +Write-Host 'DRY RUN: no Azure resources, role assignments, or project connections were changed.' +Write-Host ('Target subscription from AZURE_SUBSCRIPTION_ID: {0}.' -f $plan.subscriptionId) +Write-Host ('Target tenant from AZURE_TENANT_ID: {0}.' -f $plan.tenantId) +Write-Host ('Shared identity resource group: {0}. Managed identities are looked up there by their -project-/-account-/-search-/-function- names before provisioning.' -f $plan.identityResourceGroup) +Write-Host ('New resource group: {0}; identity resource IDs and region will be resolved by read-only admission.' -f $plan.resourceGroup) +Write-Host ('Test queue: {0}. Local shared-identity Standard variant: new account/project, Storage, Cosmos DB, Search and capability hosts.' -f $QueueName) +Write-Host 'Shared UAMIs and Azure service-role grants must already exist. Only native Cosmos data roles are created per run.' +Write-Host ('Sample model: {0} ({1}).' -f $ModelName, $ModelVersion) +Write-Host 'Output validation uses an existing project supplied by the LLM_VALIDATION_PROJECT_ENDPOINT environment variable; no validator is provisioned.' +Write-Host 'GitHub MCP connection: always provisioned; the PAT is never stored in this plan.' +Write-Host 'Function App, hosting plan, queues and handler code: always provisioned for every active queue.' +Write-Host 'Public networking. Resources incur charges until cleanup completes.' +Write-Host 'This offline preview is not ARM what-if. A live run checks identity, regional dependency eligibility and model/account quota before provisioning.' diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/PythonSampleCiHelpers.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/PythonSampleCiHelpers.ps1 new file mode 100644 index 000000000000..b7a73ebfce38 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/PythonSampleCiHelpers.ps1 @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +# Python-only adapter. Shared provisioning and cleanup must not import this file. +function Read-SampleManifest { + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $PackageRoot) + + $manifest = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + if ($manifest['schemaVersion'] -ne 1 -or @($manifest['tests']).Count -eq 0) { + throw 'The sample manifest is empty or has an unsupported schema.' + } + foreach ($test in $manifest.tests) { + if ($test['node_id'] -notmatch '^tests/samples/test_samples(?:_async)?\.py::TestSamples(?:Async)?::\w+\[[^\r\n]+\]$') { + throw 'Manifest contains a node outside the sync/async sample test harness.' + } + $samplePath = [IO.Path]::GetFullPath((Join-Path $PackageRoot $test.sample_path)) + $sampleRoot = [IO.Path]::GetFullPath((Join-Path $PackageRoot 'samples')) + [IO.Path]::DirectorySeparatorChar + if (-not $samplePath.StartsWith($sampleRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $samplePath -PathType Leaf)) { + throw 'Manifest sample path must identify an existing file under samples/.' + } + } + return $manifest +} + +function Assert-PythonSamplePartition { + param([Parameter(Mandatory)] [hashtable[]] $Plans) + + $nodes = @() + foreach ($plan in $Plans) { + $selected = @($plan['selectedTests'] | Where-Object { $null -ne $_ }) + if ($selected.Count -eq 0 -or @($selected | Where-Object { [string]::IsNullOrWhiteSpace($_['node_id']) }).Count -gt 0) { + throw 'Each test queue must contain a nonempty selection of unique test nodes.' + } + $nodes += @($selected | ForEach-Object { $_['node_id'] }) + } + if (@($nodes | Sort-Object -Unique -CaseSensitive).Count -ne $nodes.Count) { + throw 'Test queues must partition unique test nodes without repeats.' + } +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/README.md b/sdk/ai/azure-ai-projects/tests/samples/ci/README.md new file mode 100644 index 000000000000..7e85754f6ad7 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/README.md @@ -0,0 +1,414 @@ +# On-demand sample tests in Azure DevOps + +[../../../tests.yml](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests.yml) runs existing synchronous/asynchronous pytest sample cases in up to **four fresh public-network Standard Foundry environments**, sharing one permanent set of managed identities. Each test queue creates its own resource group (RG) and deletes only its owned temporary resources. + +## Workflow + +Every manually queued run generates plans, checks the selected shared identities and aggregate capacity, then provisions/tests/cleans up each nonempty queue in parallel. **Queueing this pipeline opts in to billable resources.** There is no preview/run-mode selector, automatic CI/PR trigger or YAML manual-approval stage. + +The internal **Plan** stage collects samples without execution and publishes a fresh plan per queue before the live stage may start. It changes no Azure resources and is not ARM `what-if`, a permission/capacity check or a regression-test job. Pester installation and unit-test execution are not part of an on-demand sample run. Maintainer checks are described below. + +Defaults: **up to four RG workers**, **gpt-5.2**, version **2025-12-11**, `GlobalStandard`, capacity **10 per active queue**. The **shared identity resource group** has no default and is entered on every run. `testQueueCount` accepts 1–4 as a maximum; fewer samples produce fewer workers, never empty RGs. Model settings remain configurable and `modelVersion=auto` resolves the regional catalog default. No separate region input is required: live admission obtains the identities' actual region from Azure metadata. West US 3 remains the live-validated configuration; other regions must pass all service, quota and model checks and are not guaranteed capacity. + +Selected cases are sorted by pytest node ID and divided **round-robin, without repetition**. Five cases across four workers assign cases 1 and 5 to `q1`, case 2 to `q2`, case 3 to `q3` and case 4 to `q4`. Each worker runs its assigned cases sequentially, independently of the other workers. + +## One-time setup + +Configure a pipeline to use [../../../tests.yml](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests.yml), authorize the Azure SDK package feed and `azsdk-pool`, and save these variables: + +| Variable | Setting | +| --- | --- | +| `AZURE_SUBSCRIPTION_ID` | Normal variable containing the target subscription GUID. | +| `AZURE_TENANT_ID` | Normal variable containing the target tenant GUID. | +| `LLM_VALIDATION_PROJECT_ENDPOINT` | Existing Foundry project endpoint. A normal variable is supported; secret storage is optional. | +| `MCP_GITHUB_PAT` | **Secret** containing the complete GitHub PAT, including `github_pat_` or `ghp_`. Required on every run: the MCP connection is always provisioned. | +| `PROJ_CONN_OPEN_API_KEY` | **Secret** containing the TripAdvisor service key for the OpenAPI sample. Required on every run: the OpenAPI connection is always provisioned. | + +The target IDs must be available to **both** Plan and RunSamples. Save runtime variables directly in the pipeline; there is no Library-group runtime parameter. Do not put secrets in YAML, chat, runtime parameters, command-line arguments, plans or artifacts. + +The YAML variable `azureServiceConnection` is configured as **azure-sdk-tests**, an authorized Azure Resource Manager connection. It is not a Run pipeline field. Changing the connection requires updating that configuration and authorizing the pipeline; a subscription/MI name does not select or grant access to a service connection. Prefer workload identity federation. Its service principal must already have: + +- Access to the explicit subscription and tenant; resource-group/deployment permissions, attachment of the existing UAMIs, and native Cosmos SQL role-assignment permissions. **Contributor** covers these management operations; workers do not need Azure IAM-administrator roles. +- The preconfigured subscription-scoped **Foundry User** grant. Workers assume Azure IAM grants exist; they never check, create or repair them. +- Access to the existing validation project and its **gpt-5.2** deployment, including the harness's file-upload and Code Interpreter operations. No validator resources or roles are provisioned by this pipeline. +- Registered providers: `Microsoft.CognitiveServices`, `Microsoft.Storage`, `Microsoft.DocumentDB`, `Microsoft.Search`, `Microsoft.ManagedIdentity`, `Microsoft.Web`, `Microsoft.LoadTestService`, and `Microsoft.Bing`. + +The scripts require **PowerShell 7.2+**. Azure subscription roles do not grant Azure DevOps pipeline-edit or service-connection authorization rights. Existing organization/service-connection approvals are not bypassed. + +Enter the PAT directly in the pipeline secret field. Use minimum GitHub permissions and an appropriate expiration/SSO authorization; see [GitHub MCP authentication](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md). + +### Select the shared identity resource group + +The run form asks for **`identityResourceGroup`**: the existing resource group that holds the shared identities, for example `rg-azsdk-identities-westus3-102826_164519`. It must be a valid Azure resource group name (1–90 characters; letters, digits, `_`, `-`, `.`, parentheses; no trailing period). It is not a subscription, region or MI name. + +The identities are created by [Deploy-Identities.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/shared-identities/infra/Deploy-Identities.ps1), which names them `mi-azsdk-sample-{project,account,search,function}-{region}-{timestamp}`. The pipeline needs only the `-project-`, `-account-`, `-search-` and `-function-` name segments; any prefix or suffix is accepted. + +The subscription/tenant still come from saved variables, never an active-account default or search across subscriptions. Offline **Plan** publishes only the RG name with resolution `Pending`—it invents no identity IDs or region. Read-only admission lists the managed identities in that RG and requires exactly one whose name contains each `-{purpose}-` segment (unrelated identities in the group are ignored). It then requires the `purpose`, `sample-ci-owner`, `sample-ci-retention=permanent` and `sample-ci-identity` tags set by the deployment script, one owner and one region, and binds their actual resource IDs, region, owner and principal IDs. Duplicates, missing identities, unowned/mixed sets or incomplete metadata fail closed. It verifies capacity, then publishes **resolved-sample-plan** for the workers; workers never use unresolved plans. Provider registrations and preconfigured grants are assumed to exist and are not checked. Each worker GET-rechecks identity metadata before writes and passes the four resolved resource IDs and the project principal ID to Bicep. No identity is created, replaced or granted roles by test jobs. + +### Permanent identities and service grants + +[Deploy-Identities.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/shared-identities/infra/Deploy-Identities.ps1) is the single owner of the shared identities; an authorized administrator runs it, never a worker job. Without `-Apply` it only previews (`what-if`). With `-Apply` it prompts for a subscription and region (or takes `-SubscriptionId`/`-Location`) and, after confirmation, creates: + +- A permanent RG `rg-azsdk-identities-{region}-{MMddyy_HHmmss}` and four UAMIs `mi-azsdk-sample-{project,account,search,function}-{region}-{MMddyy_HHmmss}`, tagged `purpose=azsdk-sample-identities`, `sample-ci-retention=permanent`, a shared `sample-ci-owner` token and `sample-ci-identity={purpose}`. Pass the RG name to the pipeline's `identityResourceGroup`. +- The eight **subscription-scoped** roles in [IdentityConfiguration.json](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/shared-identities/infra/IdentityConfiguration.json) for the project UAMI: Foundry User, Storage Blob Data Contributor, Storage Blob Data Owner (conditioned), Cosmos DB Operator, Search Service Contributor, Search Index Data Contributor, Storage Queue Data Contributor and Playwright Workspace Contributor. +- Subscription-scoped **Storage Blob Data Owner** and **Storage Queue Data Contributor** for the Function UAMI. +- Subscription-scoped **Search Service Contributor**, **Search Index Data Contributor**, + **Storage Blob Data Contributor**, and **Cognitive Services OpenAI User** for the Search + UAMI. The deployment script uses it to upload source documents and configure indexes; + Search uses that same identity to read blobs and generate embeddings. + +The script does **not** grant the pipeline service principal Foundry User; that subscription-scoped grant must exist already and is not checked by admission. Every run creates a new RG and identity set; nothing is reused or deleted. + +**A newly created set does not automatically run this pipeline.** Pass its RG name as `identityResourceGroup` after reviewing the target, broad role scope and regional eligibility. The [metadata resolver](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1) requires a single existing, owned identity set in that RG and never adopts unowned identities. Grants are assumed to be configured already. Do not infer workload compatibility from MI creation. + +The account identity has **no grants**. The identity bootstrap creates no test workloads. +The CI cleanup never deletes these identities or grants. The new Search grants use the +existing subscription-scoped bootstrap pattern because temporary worker resources do not +exist yet; review that broad scope before approving them. + +For an **existing permanent identity set**, an administrator can preview +[update-search-roles.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/shared-identities/infra/update-search-roles.bicep) +without creating replacement identities: + +```powershell +az deployment sub what-if --subscription '' --location '' ` + --template-file tests\samples\shared-identities\infra\update-search-roles.bicep ` + --parameters identityResourceGroupName='' searchIdentityName='' +``` + +Verify the exact existing Search identity name, owner and region first. After approval, +use `az deployment sub create` with the same explicit arguments to apply the four grants. +Do not run this administrator template from a worker or rely on workers to repair RBAC. + +**Scope warning:** the service grants reach matching existing and future resources throughout the subscription, not just temporary RGs. Shared identities reduce cross-run isolation. The approved Blob Owner condition restricts only blob tag read/write and filtering to container names ending `-azureml-agent`; it omits the original generated workspace-prefix restriction, and other Blob Owner permissions remain allowed. Do not silently broaden this pattern or use it without scope review. + +### Single-operator execution; no Environment setup + +No dedicated ADO Environment, Exclusive lock, ADO access-token mapping or Environment-administration permission is needed by this workflow. All jobs are ordinary pipeline jobs. Existing checks on service connections, pools and other protected resources remain in force. + +**The sole operator must verify that the previous run and every RG cleanup have completed before starting another live run.** The four-worker limit applies per run, not across runs; an accidental overlapping run is not automatically serialized and can exceed four RGs overall. + +Read-only admission still rejects leftover temporary sample RGs, repeated/empty queue selections and insufficient aggregate account/Search/model quota. This is a point-in-time check, not a race-free mutex or hardware reservation. The default two cases need two free account and Search slots and 20 model units; four active workers need four slots and 40 units. This workflow limit is not an Azure security boundary. + +## Run a sample + +1. Select the reviewed branch in **Run pipeline**. +2. Set `selectionKind`, `selection`, maximum worker count, **identityResourceGroup** and model options. No preview, connection, Library-group or region fields are shown. +3. Before queueing, verify the saved variables, configured connection, selected identity set and previous run's cleanup. **Run** authorizes provisioning billable resources after internal planning and live admission succeed. +4. Inspect offline selections/intents in **sample-plan** and actual admitted identity IDs/region in **resolved-sample-plan**, under each active queue. Each node appears exactly once; workers run automatically after admission. For plan-only inspection without a live run, maintainers can invoke the local selector/planner with fictional targets; this is not a separate pipeline mode. +5. Verify every queue's ADO **Tests** results and attachments and final deployment state `CleanedUp` in **sample-results-q1** through **sample-results-q4**, as applicable. + +To verify repeatability, wait for all queues to pass and finish verified cleanup before queueing a second fresh batch. Each queue gets a new plan, owner token and RG; shared identities alone persist. Never reuse the first batch's workloads or overlap retries. + +### Selection examples + +| `selectionKind` | `selection` | +| --- | --- | +| `sample-path` | `samples/agents/tools/sample_agent_mcp_with_project_connection.py` | +| `sample-path` | `samples/agents/tools/sample_agent_mcp*project_connection*.py` (sync and async) | +| `test-id` | `sample_agent_mcp_with_project_connection` | +| `test-id` | `*test_samples_async.py::*[sample_agent_mcp_with_project_connection_async]` | +| `test-id` | `sample_agent_basic;sample_agent_mcp_with_project_connection` (OR) | + +`*` and `?` are wildcards; brackets in pytest IDs are literal. Matching is case-insensitive, slash-normalized and deduplicated. Semicolons/newlines separate OR patterns. Zero matches fail; selection never falls back to all tests. Harness exclusions remain excluded. A selected sample that needs an environment variable nothing supplies is rejected during planning, before any resource is created: it is logged as an error and skipped, the other samples still run, and the run fails at the end in `ReportRejectedSamples`. Supported sample inputs come from the root `environment` output in [infra/main.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/main.bicep), `AZURE_SUBSCRIPTION_ID`, `AZURE_TENANT_ID`, `AZURE_RESOURCE_GROUP`, and the pipeline-supplied `LLM_VALIDATION_PROJECT_ENDPOINT`. Every active queue's plan saves the complete provisioned input names as `environmentBindings`, which `run_samples.py` enforces. A queue whose samples were all rejected gets no worker. + +## Shared provisioning and language adapters + +The infrastructure entry points have no dependency on Python, pytest, sample paths, or +sample queries. They can prepare the same environment for .NET, Java, JavaScript/TypeScript, +Go, or Python tests. The Azure resources always deploy as one complete bundle. + +| Layer | Entry points | +| --- | --- | +| Shared offline planning | [New-ResourceDeploymentPlan.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/New-ResourceDeploymentPlan.ps1) | +| Shared admission and deployment | [Invoke-ResourceProvisioning.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-ResourceProvisioning.ps1), [SampleCiHelpers.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1) | +| Shared cleanup | [Remove-SampleResources.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/Remove-SampleResources.ps1) | +| Python-only selection and admission adapter | [New-SampleDeploymentPlan.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/New-SampleDeploymentPlan.ps1), [Invoke-SampleProvisioning.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-SampleProvisioning.ps1), [PythonSampleCiHelpers.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/PythonSampleCiHelpers.ps1) | +| Python-only execution | [select_samples.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/select_samples.py), [run_samples.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/run_samples.py), [sample_ci_attachments.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/sample_ci_attachments.py) | + +### Adding resources without editing PowerShell + +1. Add a Bicep resource or module to [infra/main.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/main.bicep). + Declare module dependencies using Bicep inputs/outputs, not script-side deployment steps. + Do not add a sample-selection condition. +2. Expose its non-secret test inputs as explicit uppercase keys in the root + `output environment object = { ... }` block. Offline planning discovers these keys + without an Azure login or Bicep compiler. Keep one key per line, using the existing + format; computed keys and an output built with `union(...)` are not supported. + The compiled-template check verifies that discovered keys match actual ARM outputs. +3. If a new provider or extra subscription-scoped grant is required, have an + administrator register the provider or approve and configure the bootstrap grant. + Workers assume provider registrations and grants for the existing + project/account/search/function identities already exist; they never check or create them. + The existing baseline project grants and conditioned Blob Owner grant remain mandatory. +4. Run the offline regression suites and Bicep checker below, then validate live. + +No PowerShell changes are needed for ordinary ARM resources and their environment +outputs. The Python runner also registers new output names as pytest options automatically: +it passes only the validated binding **names** through +`AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES`; values still use the existing CLI options. +There is no resource-specific option list or sanitized default for these bindings. +Outside that runner, manual pytest invocations using extra environment CLI options must +set the same metadata first, for example +`$env:AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES = '["NEW_RESOURCE_ENDPOINT"]'` +before passing `--new-resource-endpoint=...`. Only names belong in that JSON array, +not values. Existing preparer-derived options, live/recording flags and playback +defaults are unchanged; directly running a sample script still uses its usual environment. + +This is not a generic imperative-task framework. Code publication, data-plane initialization, +specialized quota/readiness checks, new identity types, or secret delivery may still need +reviewed scripts. The shared Function publisher is such an explicit post-deployment step; +its Python handler is a test dependency, not a dependency on the Python SDK test harness. + +### Calling from another SDK language + +Distribute the shared scripts, the complete [infra](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra) directory, and +[function_app](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/tests/samples/ci/function_app) and +[search_documents](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/tests/samples/ci/search_documents) +together at a pinned revision; keep their relative layout. +The shared entry points require PowerShell 7.2+, Azure CLI with Bicep, and the existing +authorized service-principal context. They do not authenticate on behalf of the caller. +Function remote build uses the packaged handler requirements. + +For example, after setting `AZURE_SUBSCRIPTION_ID` and `AZURE_TENANT_ID`: + +```powershell +# Offline: no manifest or Python package is required. +.\New-ResourceDeploymentPlan.ps1 -OutputDirectory .\plans\q1 -QueueName q1 ` + -IdentityResourceGroup '' -DefinitionId 8392 -BuildId 12345 -JobAttempt 1 + +# Read-only admission; workers must use the resolved plan it writes. +.\Invoke-ResourceProvisioning.ps1 -BatchPlanDirectory .\plans -ResolvedPlanDirectory .\resolved + +# Billable deployment; MCP_GITHUB_PAT must be supplied securely in this process's environment. +.\Invoke-ResourceProvisioning.ps1 -PlanPath .\resolved\q1\deployment-plan.json ` + -StatePath .\results\q1\deployment-state.json -QueueName q1 -ConfirmProvisioning +``` + +The schema-version-1 JSON plan contains numeric run identifiers, queue `q1` through `q4`, +fresh ownership metadata, model configuration, identity resolution, and `environmentBindings`. +The shared planner creates it; callers should not invent resource names or identity bindings. +If running under Azure DevOps, use the actual definition/build/attempt IDs; runtime ownership +checks reject identifiers from another run. Other CI systems must supply equivalent numeric IDs. + +After deployment, require state `status=Ready`, then load the state's `environment` +object into the language-specific test process. All bindings are nonempty strings; +only declared non-secret names are accepted. Supply any external validation configuration +separately. Shared admission does not inspect language-specific test metadata; each adapter +must validate its own test partition before calling admission. + +In the caller's `finally`/always-run cleanup step, invoke: + +```powershell +.\Remove-SampleResources.ps1 -StatePath .\results\q1\deployment-state.json -QueueName q1 -ConfirmCleanup +``` + +A non-Python adapter and pipeline are not shipped here. The shared interface is prepared +for reuse; cross-language test execution still needs its own adapter and live validation. + +## Bing Search, Bing Custom Search and OpenAPI integration + +The native modules +[bing-search.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-search.bicep), +[bing-custom-search.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-custom-search.bicep) +and [openapi.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/openapi.bicep) +port the corresponding `foundry-project-pool` provisioning scripts. All three are +unconditional, use fresh run-owned resources and depend only on Standard setup, not on +models or Search indexing. No deployment scripts, worker IAM grants or new identities +are added. The existing RG cleanup owns these accounts, configurations and connections. + +Bing Search creates `Microsoft.Bing/accounts@2020-06-10` with kind `Bing.Grounding`, +SKU `G1`, location `global` and statistics disabled. Its project connection is `bing`, +category `GroundingWithBingSearch`. Bing Custom Search creates a separate global account +with kind `Bing.GroundingCustomSearch`, SKU `G2`, statistics disabled and connection +`bing-custom`, category `GroundingWithCustomSearch`. Both connections use API +`2025-10-01-preview`, `ApiKey` authentication, `isSharedToAll: false`, the actual +account endpoint and the pool's Azure resource metadata. ARM obtains `key1` directly +through `listKeys`; no Bing keys are pipeline inputs or environment outputs. +The bundled Bicep catalog lacks these Bing resource types, so their declarations have +narrow `BCP081` suppressions. The compiled contract checker verifies their ARM shapes. + +The `agentdoc` child configuration uses API `2025-05-01-preview` and exactly these +hardcoded allowed pages (all with `includeSubPages: false` and `boostLevel: Default`): + +- `https://learn.microsoft.com/en-us/azure/ai-foundry/agents/overview` +- `https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/overview` +- `https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-grounding` +- `https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-custom-search` + +Blocked and pinned domain lists are empty. This configuration is not read from `.env` +and does not broaden to the full documentation site. Outputs include the Bing connection +IDs/names, `BING_CUSTOM_SEARCH_INSTANCE_NAME` and a noninteractive `BING_CUSTOM_USER_INPUT` +about Agent Service and search tools. Bing grounding has additional costs and +[terms](https://www.microsoft.com/bing/apis/grounding-legal-enterprise); sample data +flows outside the Azure compliance boundary. An administrator must register +`Microsoft.Bing` and ensure subscription eligibility for both SKUs before a live run; +the pipeline does not register providers or repair prerequisites. + +OpenAPI creates connection `openapi` with category/authentication `CustomKeys`, +target `_`, `isSharedToAll: false`, metadata type `openapi`, and +`credentials.keys.key`, matching the pool script. It is not an OpenAI account. +The external TripAdvisor service key must be saved as the ADO secret +`PROJ_CONN_OPEN_API_KEY`; the provisioning task alone maps it into a required +`@secure()` Bicep parameter. Missing, whitespace-only or unresolved pipeline values +fail before Azure calls or resource creation. No `.env` file is imported and no key +is passed on the command line. ARM stores it in the connection, not in outputs, +plans, deployment state or pytest arguments; the runner also strips inherited copies +from the sample subprocess environment. Outputs expose `OPENAPI_PROJECT_CONNECTION_ID`, +`OPENAPI_PROJECT_CONNECTION_NAME` and the pool's `TRIPADVISOR_PROJECT_CONNECTION_ID` +alias. Bicep cannot validate the external key's service access or quota; an actual +OpenAPI sample run is required to verify it. + +## Browser automation integration + +[browser-automation.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/browser-automation.bicep) +ports the restored `foundry-project-pool` workspace and connection provisioning scripts. +Every active queue gets a fresh `Microsoft.LoadTestService/playwrightWorkspaces` resource +using API `2025-09-01`, in the admitted identity region. The workspace name retains the +full run hash, local authentication is disabled, and regional affinity is enabled. +Workspace creation and the connection are native ARM resources; no deployment script, +API key, browser installation or new pipeline secret is required. + +The project connection is named `browserAutomation`, has category `PlaywrightWorkspace`, +uses `ProjectManagedIdentity` authentication and audience +`https://mcp.playwright.microsoft.com`, and is shared within the project. Its MCP target is +derived from the workspace's actual `dataplaneUri`, replacing the API hostname with the MCP +hostname and appending `/mcp`, exactly as in the restored pool script. Browser provisioning +depends only on Standard setup, not on model deployments or Search indexing. +The connection shape also matches the +[official Foundry sample](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/csharp/hosted-agents/agent-framework/browser-automation/infra-modules/playwright-connection.bicep). +The `2025-04-01-preview` Bicep type enum omits `ProjectManagedIdentity`, so only that +property's `BCP036` warning is suppressed; the compiled contract checker pins its value. + +Unlike the pool script's system-assigned project identity, this bundle uses its existing, +bootstrap-owned **project UAMI**. The project identity must already have **Playwright +Workspace Contributor** (`78cf819f-0969-4ebe-8759-015c6efcd5bf`). New identity sets receive +that role from the bootstrap configuration. It is subscription-scoped, matching the +existing bootstrap pattern for disposable RGs; review that broad scope before approval. +Workers create neither identities nor IAM role assignments. + +An administrator must also register +`Microsoft.LoadTestService` and verify workspace availability in the chosen region; workers +never register providers or repair prerequisites. West US 3 is listed as supported by +the provider, but registration and RBAC are independent of regional support. + +Root outputs expose `BROWSER_AUTOMATION_PROJECT_CONNECTION_ID`, +`BROWSER_AUTOMATION_PROJECT_CONNECTION_NAME`, and `PLAYWRIGHT_SERVICE_RESOURCE_ID`. +Existing offline planning and dynamic pytest options consume these names automatically; +no resource-specific PowerShell plumbing, option mappings or sanitized defaults are added. +The existing Browser Automation sample uses the provisioned connection. Browser sessions +are created by the sample at runtime, not by Bicep; live-view URLs and session identifiers +must be treated as sensitive. The queue's existing RG cleanup removes the workspace and +connection, not the permanent shared identity or its role assignment. + +## AI Search integration + +[ai-search.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/ai-search.bicep) +ports the Search content provisioning from `foundry-project-pool` into the unconditional +resource bundle. It reuses the Standard setup's S1 Search service, storage account, +AAD-authenticated project connection and pre-existing Search UAMI. Semantic search uses +the free tier. It adds a private `documents` container and a `text-embedding-3-small` +version `1` deployment with GlobalStandard capacity `10` and `1536` dimensions. The region must +support that embedding deployment and have its own remaining model quota; the chat-model +preflight does not check embedding quota. ARM validates this additional deployment. +The Search module waits for the chat-model/MCP module before deploying its embedding +model: concurrent model deployment operations on the same Foundry account are rejected +with `RequestConflict`. + +Search indexes, data sources, skillsets and indexer execution are +[data-plane APIs](https://learn.microsoft.com/azure/search/search-how-to-create-indexers), +not native ARM resources. A Bicep-managed `Microsoft.Resources/deploymentScripts` resource +runs the packaged, standard-library-only +[search-indexing.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/search-indexing.py) +under the shared Search UAMI. No new PowerShell publisher, SDK dependency, API key, +storage key or IAM grant is required in workers. + +The script uploads the CI-local +[search_documents/product_info.md](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/search_documents/product_info.md) +in Markdown and plain-text formats, creates the chunk/vector/semantic index, a keyless blob data source, a split and +embedding skillset, and separate Markdown and document indexers. Data sources, embedding +skills and query-time vector generation explicitly select the Search UAMI for outbound authentication; +[managed-identity connections](https://learn.microsoft.com/azure/search/search-how-to-managed-identities) +use Search API `2026-04-01`. + +Indexer reset/run is explicit on each script execution, including retries. The script +waits for fresh, successful executions with nonzero processed documents, rejects indexing +errors and warnings, and verifies searchable content before deployment can finish. +Authorization propagation, throttling and transient HTTP failures have bounded retries; +missing grants or model/embedding failures still fail the deployment. The deployment script +has a 30-minute timeout and always removes its supporting resources after execution. +All indexing resources are created even when no selected sample uses Search. + +Root outputs provide `AI_SEARCH_PROJECT_CONNECTION_ID`, `AI_SEARCH_INDEX_NAME`, +`AZURE_AI_SEARCH_ENDPOINT`, `AZURE_AI_EMBED_DEPLOYMENT_NAME`, +`AZURE_AI_EMBED_DIMENSIONS`, `AZURE_BLOB_CONTAINER_NAME`, and `AI_SEARCH_USER_INPUT`. +The last value supplies a question about the indexed sample product so the Search sample +does not wait for interactive input. Output names are discovered by the existing planner +and registered by the Python runner; no new option mapping or sanitized defaults are added. +This product-content index is for sample execution, not the separate Agent Lightning +question-answering service tests, which require their own document corpus. + +## Function App integration + +[infra/function-app.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/function-app.bicep) translates the Function App infrastructure from the `foundry-project-pool` provisioning script. The pipeline always deploys it and publishes [function_app](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/tests/samples/ci/function_app) for every active queue, even when no selected sample uses Function or queue bindings. There is no Function enable/disable parameter. + +The sample query selects tests, not infrastructure. Every active queue receives the same resource bundle, environment bindings and prerequisite grant checks. Planning still validates each selected sample's required environment-variable names and rejects unsupported samples; that validation never adds or removes resources. + +Provisioning values are internal inputs, not environment-variable configuration: + +| Input | Source | +| --- | --- | +| Account and project names | Standard module outputs wired directly to dependent modules by the root Bicep template. | +| Agent name | Generated run plan. | +| Model settings | Run plan and validated regional model catalog. | +| `storageAccountName` | Standard setup's storage output. | +| `sharedFunctionIdentityResourceId` | Function UAMI in the admitted shared identity set. | + +These values are passed directly to Bicep. [infra/sample-resources.bicepparam](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicepparam) supplies only the `MCP_GITHUB_PAT` and `PROJ_CONN_OPEN_API_KEY` secrets to secure Bicep parameters; no names, identity IDs, model settings, or Function toggle are read from environment variables there. + +The Function template creates a Linux **EP1** plan, a Python **3.11** Function App with Functions host **v4**, and the `weather-input-queue` and `weather-output-queue` queues. Host storage and the queue binding use the existing Function UAMI without storage keys or connection strings. Each run creates a fresh app and declares its complete settings directly; reading and merging that same appsettings resource during deployment would introduce an ARM circular dependency. Premium capacity is billable for every active queue, regardless of sample selection, and must be available in the selected region. + +Before any queue can provision resources, admission and worker preflight require preconfigured subscription-scoped Storage Blob Data Owner and Storage Queue Data Contributor grants for the Function identity, and Storage Queue Data Contributor for the project identity. Worker jobs never create these grants. The shared-identity bootstrap includes them for new sets; existing sets require a separately approved grant update. + +The pipeline publishes only the three handler package files and checks for `queue_trigger` indexing up to 30 times, ten seconds apart, before starting the sample. This allows for host startup after publication; missing indexing or an Azure query error still fails provisioning. The five non-secret Function outputs become sample inputs: `FUNCTION_APP_NAME`, `FUNCTION_APP_URL`, `STORAGE_INPUT_QUEUE_NAME`, `STORAGE_OUTPUT_QUEUE_NAME`, and `STORAGE_QUEUE_SERVICE_ENDPOINT`. These are sample environment variables, not provisioning configuration. No `FUNCTION_APP_KEY` is retrieved or output. + +The package's [pytest environment hook](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/conftest.py) registers these Function inputs +from the runner's binding-name metadata, just like any other provisioned resource. +Both hyphenated and underscored CLI spellings are accepted. No Function-specific option +mapping or sanitized playback default is needed. Explicit CLI values are copied to the +sample environment; an omitted value leaves the existing environment unchanged. + +## Safety and results + +- **Explicit targets and identity:** task, plan and state subscription/tenant IDs must match the same authorized service-principal identity. No fallback to an active account, another tenant, or a developer's login. +- **Live preflight:** check Search S1 offerings/service quota, Cosmos subscription-region access, the pinned template's Storage SKU restrictions, model availability, and separate account/model quotas. Missing or invalid evidence fails closed. Provider registrations and IAM grants are assumed to exist; missing prerequisites surface as Azure deployment or runtime errors. These checks do not reserve capacity or change region, SKU, registration or policy. +- **Reviewed local Bicep:** [infra/standard-shared-identities.bicep](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-shared-identities.bicep) is a local UAMI composition based on the unchanged official reference pinned by [infra/standard-setup.lock.json](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-setup.lock.json), not an unchanged upstream deployment. The official 14 sources remain checksum-verified maintainer references; workers do not download them. State is saved before the first resource mutation. +- **Run-unique names:** the local template retains the full 13-character `uniqueString` hash of the resource group ID and saved deployment timestamp for account, project, Storage, Cosmos DB and Search names. It does not truncate the hash to four characters, which caused global storage-name collisions. The hash stays stable within a plan and changes for each fresh run. +- **Fresh workloads only:** every queue creates a Foundry account/project, Storage, Cosmos DB, Search, three AAD connections, two capability hosts, a native Cosmos data grant, chat and embedding model deployments, an MCP connection, an indexed document container, a keyless Playwright workspace and project connection, Bing Search and Bing Custom Search accounts/connections with a custom configuration, an OpenAPI connection, and its own Function App, hosting plan and queues with published handler code. Only the four selected UAMIs are reused. No worker Azure IAM assignments, Application Insights or ACR are created. `GlobalStandard` inference may run globally despite resource colocation; Bing resources use `global`. +- **Secrets:** only provisioning receives the PAT, through an environment-backed Bicep `@secure()` parameter. The connection stores the full Bearer token; the sample receives only its connection ID. No job explicitly maps an ADO access token; the sample subprocess strips any inherited token and the PAT. The external validator endpoint is passed only through the preflight/test environment and excluded from plans, state, Bicep outputs and pytest arguments. +- **Additional connection secrets:** the OpenAPI key is also provisioning-only and supplied through a secure Bicep parameter; the runner strips inherited copies before launching samples. Bing keys are retrieved only by ARM expressions within the connection resources. None are environment outputs, plan/state values or pytest arguments. +- **Readiness:** bounded read-only retries precede sample execution. An already-started sample is never retried automatically. +- **Logs:** results attach the shared executor's sanitized API/print log and print-only output log through native JUnit markers. Captured console/JUnit reports are redacted; raw sample prints/live logging cannot bypass capture. Only verified run-owned attachment paths are published. Validator input remains the original in-memory output. Logs can still contain test data; use suitable access and retention settings. +- **Cleanup:** each queue's `always()` step attempts deletion after failure/cancellation, verifying its independently supplied queue, exact state/plan binding, full group ID and all four CI purpose/run/owner/queue tags. The group must be absent before recording `CleanedUp`; `-WhatIf` never marks completion. Sibling queues, shared identities/validation resources, permanent grants and separately retained projects are untouched. + +Provisioning can incur standing charges. Agent loss, hard termination or Azure errors can prevent cleanup; expiry tags are metadata, **not automatic deletion**. Review state and inventory before manual cleanup. Soft-deleted Foundry records are not purged automatically. Use task errors, per-queue state/inventory and pytest output for troubleshooting; raw secure deployment bodies are suppressed. Compilation and bootstrap verification do not prove live UAMI Standard deployment or sample success. + +## Files + +| Runtime file | Responsibility | +| --- | --- | +| [New-SampleDeploymentPlan.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/New-SampleDeploymentPlan.ps1) | Python compatibility checks and test metadata added to a shared infrastructure plan. | +| [Invoke-SampleProvisioning.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/Invoke-SampleProvisioning.ps1) | Python partition validation and delegation to the shared provisioner; preserves existing pipeline calls. | +| [Remove-SampleResources.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/Remove-SampleResources.ps1) | Exact queue/ownership-checked cleanup. | +| [SampleCiHelpers.ps1](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1) | Language-neutral identity metadata, ownership, output, quota and error-handling guards. | +| [select_samples.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/select_samples.py), [run_samples.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/run_samples.py) | Real sample collection/selection and live pytest invocation. | +| [sample_ci_attachments.py](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/sample_ci_attachments.py) | Sanitized shared-log attachments and captured report publication. | + +## Maintainer validation (separate from sample runs) + +Retained Python unit tests live in this directory; mocked PowerShell tests and [the Bicep checker](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/tests/samples/ci/validation/Test-BicepTemplates.ps1) live under [validation](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/tests/samples/ci/validation). No new PR pipeline is introduced here. + +- With dotenv/live execution disabled, run the Python tests using `--confcutdir=tests/samples/ci` to avoid parent service fixtures. +- Run all PowerShell `*.Tests.ps1` files under validation using **Pester 4.10.1**. The suites isolate Azure calls and cover identity resource-group resolution and metadata checks. On-demand pipeline agents do not need Pester. +- The Bicep checker compiles the root bundle, all workload modules, the resource-group template, the administrator-only Search role update, and the secret-only parameter file with empty PAT and OpenAPI values. It checks unconditional modules, environment-contract parity, Function hosting defaults, identity-based settings, keyless Playwright configuration, Bing account/connection shapes, the four pinned Custom Search pages, secure OpenAPI credentials and non-secret outputs, and rejects Azure IAM writes in worker templates including nested deployments. Its optional `StandardTemplatePath` input also verifies and compiles the 14 unchanged cached official sources. +- Use the repository Black configuration, PowerShell syntax checks and `git diff --check`. Real harness collection must use `PYTHON_DOTENV_DISABLED=true` and fictional target IDs and must not execute samples. diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/Remove-SampleResources.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/Remove-SampleResources.ps1 new file mode 100644 index 000000000000..d68f5a10cc11 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/Remove-SampleResources.ps1 @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] [string] $StatePath, + [Parameter(Mandatory)] [ValidateSet('q1', 'q2', 'q3', 'q4')] [string] $QueueName, + [switch] $ConfirmCleanup +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'SampleCiHelpers.ps1') +if (-not (Test-Path -LiteralPath $StatePath)) { + Write-Host 'No deployment attempt was recorded; no cleanup is needed.' + return +} +$state = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json -AsHashtable +Assert-SampleRunPlan -Plan $state.plan -QueueName $QueueName +$expectedDeployment = 'sample-ci-rg-' + $state.plan.ownerToken +if ($state['resourceGroupDeployment'] -cne $expectedDeployment -or $state['subscriptionId'] -ine $state.plan.subscriptionId -or + $state['tenantId'] -ine $state.plan.tenantId) { + throw 'Invalid cleanup state. No resources were deleted.' +} +$null = Set-SampleSubscriptionContext -Plan $state.plan +$exists = Invoke-SampleAzure -Arguments @('group', 'exists', '--name', $state.plan.resourceGroup, '--subscription', $state.subscriptionId) +if ($exists) { + $group = Invoke-SampleAzure -Arguments @('group', 'show', '--name', $state.plan.resourceGroup, '--subscription', $state.subscriptionId) + Assert-SampleResourceGroupOwnership -Plan $state.plan -ResourceGroup $group -SubscriptionId $state.subscriptionId + Write-Host ('Cleanup preview: delete only run-owned resource group {0}, including its temporary resources.' -f $state.plan.resourceGroup) + if (-not $ConfirmCleanup) { Write-Host 'Dry run only. Supply -ConfirmCleanup to apply this cleanup.'; return } + if (-not $PSCmdlet.ShouldProcess($state.plan.resourceGroup, 'Delete run-owned temporary resource group')) { return } + $null = Invoke-SampleAzure -Arguments @('group', 'delete', '--name', $state.plan.resourceGroup, '--subscription', $state.subscriptionId, '--yes') +} +if (-not $ConfirmCleanup) { return } +if (-not $PSCmdlet.ShouldProcess($expectedDeployment, 'Delete the run-owned subscription deployment history')) { return } +$remaining = Invoke-SampleAzure -Arguments @('group', 'exists', '--name', $state.plan.resourceGroup, '--subscription', $state.subscriptionId) +if ($remaining) { + throw 'The run-owned resource group still exists. Cleanup was not marked complete.' +} +$null = Invoke-SampleAzure -Arguments @('deployment', 'sub', 'delete', '--name', $expectedDeployment, '--subscription', $state.subscriptionId) +$state['status'] = 'CleanedUp' +$state | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath $StatePath -Encoding utf8 +Write-Host 'Run-owned temporary resource cleanup completed.' diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1 new file mode 100644 index 000000000000..099fcdf93dc3 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/SampleCiHelpers.ps1 @@ -0,0 +1,667 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +Set-StrictMode -Version Latest + +function Invoke-SampleAzure { + [CmdletBinding()] + param([Parameter(Mandatory)] [string[]] $Arguments, [switch] $Sensitive) + + if ($Sensitive) { + # Capture both streams in memory. Only parsed error codes may leave a failed secret-bearing request. + $output = & az @Arguments --only-show-errors --output json 2>&1 + } else { + $output = & az @Arguments --only-show-errors --output json + } + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + $message = "Azure operation '$($Arguments[0]) $($Arguments[1])' failed (exit $exitCode)." + if ($Sensitive) { + $codes = @(Get-SampleAzureCliErrorCodes -Text ($output -join [Environment]::NewLine)) + if ($codes.Count -gt 0) { $message += ' ARM error codes: ' + ($codes -join ', ') + '.' } + } + throw $message + } + $text = $output -join [Environment]::NewLine + if (-not [string]::IsNullOrWhiteSpace($text)) { + try { + return ($text | ConvertFrom-Json -AsHashtable -ErrorAction Stop) + } catch { + if ($Sensitive) { throw 'Azure operation returned invalid protected JSON; response content was suppressed.' } + throw + } + } +} + +function Get-SampleStandardTemplate { + param([Parameter(Mandatory)] [string] $OutputDirectory) + + $sourceLock = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'infra/standard-setup.lock.json') -Raw | ConvertFrom-Json -AsHashtable + if ($sourceLock.repository -cne 'microsoft-foundry/foundry-samples' -or $sourceLock.commit -notmatch '^[a-f0-9]{40}$') { + throw 'Invalid official Standard setup source pin.' + } + $baseUri = 'https://raw.githubusercontent.com/{0}/{1}/{2}/' -f $sourceLock.repository, $sourceLock.commit, $sourceLock.path + $client = [Net.Http.HttpClient]::new() + try { + foreach ($entry in $sourceLock.files.GetEnumerator()) { + if ($entry.Key -notmatch '^(main\.bicep|modules-standard/[a-z-]+\.bicep)$' -or $entry.Value -notmatch '^[a-f0-9]{64}$') { + throw 'Invalid Bicep source path or checksum in the source pin.' + } + $bytes = $client.GetByteArrayAsync($baseUri + $entry.Key).GetAwaiter().GetResult() + $checksum = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() + if ($checksum -cne $entry.Value) { throw "Official Bicep source checksum mismatch: $($entry.Key)" } + $destination = Join-Path $OutputDirectory $entry.Key + $null = New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force + [IO.File]::WriteAllBytes($destination, $bytes) + } + } finally { + $client.Dispose() + } + Write-Host ('Verified {0} official Standard setup Bicep files at commit {1}.' -f $sourceLock.files.Count, $sourceLock.commit) + return (Join-Path $OutputDirectory 'main.bicep') +} + +function Get-SampleBicepEnvironmentNames { + param([Parameter(Mandatory)] [string] $BicepPath) + + # Text scan keeps planning offline; Test-BicepTemplates.ps1 asserts parity with the compiled template. + $block = [regex]::Match((Get-Content -LiteralPath $BicepPath -Raw), '(?ms)^output environment object = \{\s*\r?\n(?.*?)^\}') + $names = @() + if ($block.Success) { + $names = @([regex]::Matches($block.Groups['body'].Value, '(?m)^ (?[A-Z][A-Z0-9_]*):') | + ForEach-Object { $_.Groups['name'].Value }) + } + if ($names.Count -eq 0) { throw "Cannot read the 'environment' output names from '$BicepPath'." } + return $names +} + +function Get-SampleEnvironmentNames { + $names = @(@(Get-SampleBicepEnvironmentNames -BicepPath (Join-Path $PSScriptRoot 'infra/main.bicep')) + + @('AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_RESOURCE_GROUP') | Sort-Object -Unique) + if (@($names | Where-Object { $_ -match '(^|_)(PAT|SECRET|KEY|TOKEN|PASSWORD|CREDENTIALS?)(_|$)' }).Count -gt 0) { + throw 'Infrastructure environment outputs must not contain secret-looking bindings.' + } + return $names +} + +function Assert-SampleEnvironment { + param([Parameter(Mandatory)] [System.Collections.IDictionary] $Environment, + [Parameter(Mandatory)] [string[]] $Names) + + $unexpected = @($Environment.Keys | Where-Object { $_ -cnotin $Names }) + $missing = @($Names | Where-Object { + -not $Environment.Contains($_) -or $Environment[$_] -isnot [string] -or [string]::IsNullOrWhiteSpace($Environment[$_]) + }) + if ($unexpected.Count -gt 0 -or $missing.Count -gt 0 -or + @($Names | Where-Object { $_ -cnotmatch '^[A-Z][A-Z0-9_]*$' -or + $_ -match '(^|_)(PAT|SECRET|KEY|TOKEN|PASSWORD|CREDENTIALS?)(_|$)' }).Count -gt 0) { + throw 'Deployment outputs must match the declared non-secret environment bindings; values were suppressed.' + } +} + +function Get-SampleSubscriptionId { + param([AllowNull()] [AllowEmptyString()] [string] $SubscriptionId = $env:AZURE_SUBSCRIPTION_ID) + + if ($SubscriptionId -notmatch '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$') { + throw 'Set AZURE_SUBSCRIPTION_ID to the target Azure subscription GUID; no resources were changed.' + } + return ([guid] $SubscriptionId).ToString('D') +} + +function Get-SampleTenantId { + param([AllowNull()] [AllowEmptyString()] [string] $TenantId = $env:AZURE_TENANT_ID) + + if ($TenantId -notmatch '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$') { + throw 'Set AZURE_TENANT_ID to the target Azure tenant GUID; no resources were changed.' + } + return ([guid] $TenantId).ToString('D') +} + +function Set-SampleSubscriptionContext { + param([Parameter(Mandatory)] [hashtable] $Plan) + + $subscription = Get-SampleSubscriptionId + $tenant = Get-SampleTenantId + Assert-SampleRunPlan -Plan $Plan + if ($subscription -ine $Plan.subscriptionId) { + throw 'AZURE_SUBSCRIPTION_ID does not match the deployment plan; no resources were changed.' + } + if ($tenant -ine $Plan.tenantId) { + throw 'AZURE_TENANT_ID does not match the deployment plan; no resources were changed.' + } + $active = Invoke-SampleAzure -Arguments @('account', 'show') -Sensitive + if ($active['user']['type'] -ne 'servicePrincipal') { + throw 'An authorized Azure service connection application is required, not a personal login.' + } + try { + $target = Invoke-SampleAzure -Arguments @('account', 'show', '--subscription', $subscription) -Sensitive + } catch { + throw 'The authorized service connection cannot access AZURE_SUBSCRIPTION_ID; no resources were changed. Use an approved connection for the target subscription.' + } + if ($target['id'] -ine $subscription -or $target['state'] -ne 'Enabled' -or + $target['tenantId'] -ine $tenant -or $active['tenantId'] -ine $tenant -or + $target['user']['type'] -ne 'servicePrincipal' -or + $target['user']['name'] -ine $active['user']['name']) { + throw 'The target subscription must be enabled and accessible by the same application and tenant specified by AZURE_TENANT_ID; no resources were changed.' + } + $identity = Get-SampleRunnerIdentity -SubscriptionId $subscription + if ($identity.tenantId -ine $tenant -or $identity.clientId -ine $target['user']['name']) { + throw 'The target subscription application identity does not match the authorized connection; no resources were changed.' + } + # Select only a subscription already accessible to this application. Never log in or change identities. + if ($active['id'] -ine $subscription) { + $null = Invoke-SampleAzure -Arguments @('account', 'set', '--subscription', $subscription) -Sensitive + } + $confirmed = Invoke-SampleAzure -Arguments @('account', 'show') -Sensitive + if ($confirmed['id'] -ine $subscription -or $confirmed['tenantId'] -ine $identity.tenantId -or + $confirmed['user']['type'] -ne 'servicePrincipal' -or $confirmed['user']['name'] -ine $identity.clientId) { + throw 'Could not confirm the requested subscription application identity; no resources were changed.' + } + Write-Host ('Subscription preflight: AZURE_SUBSCRIPTION_ID={0}; AZURE_TENANT_ID={1}.' -f $subscription, $tenant) + return $identity +} + +function Assert-SampleRunPlan { + param([Parameter(Mandatory)] [hashtable] $Plan, [string] $QueueName = $env:SAMPLE_QUEUE_NAME) + + if ($Plan['schemaVersion'] -ne 1 -or $Plan['definitionId'] -notmatch '^\d+$' -or $Plan['buildId'] -notmatch '^\d+$' -or + $Plan['jobAttempt'] -notmatch '^\d+$' -or $Plan['ownerToken'] -notmatch '^[a-f0-9]{32}$' -or + $Plan['queueName'] -cnotmatch '^q[1-4]$') { + throw 'Invalid CI run ownership metadata.' + } + if ($QueueName -and $QueueName -cne $Plan.queueName) { + throw 'The deployment plan belongs to a different test queue; no resources were changed.' + } + $expectedName = 'azsdk-samples-{0}-{1}-{2}-{3}-{4}' -f $Plan.definitionId, $Plan.buildId, $Plan.queueName, $Plan.jobAttempt, $Plan.ownerToken.Substring(0, 8) + if ($Plan['resourceGroup'] -cne $expectedName) { + throw 'Resource group does not match the run-owned ephemeral naming convention.' + } + $null = Get-SampleSubscriptionId -SubscriptionId $Plan['subscriptionId'] + $null = Get-SampleTenantId -TenantId $Plan['tenantId'] + if ($Plan.ContainsKey('identityResourceGroup')) { + $null = Get-SampleIdentityResourceGroup -Name $Plan['identityResourceGroup'] + if ($Plan['identityResolution'] -cnotin @('Pending', 'Resolved')) { throw 'Invalid shared identity resolution metadata.' } + if ($Plan['identityResolution'] -ceq 'Pending') { + if ($Plan['location'] -cne '' -or $Plan['sharedIdentities'] -isnot [System.Collections.IDictionary] -or + $Plan.sharedIdentities.Count -ne 0) { throw 'Pending identity selection must not invent resource IDs or a region.' } + } else { + Assert-SampleResolvedIdentityBindings -Plan $Plan + } + } + foreach ($binding in @(@('SYSTEM_DEFINITIONID', 'definitionId'), @('BUILD_BUILDID', 'buildId'))) { + $current = [Environment]::GetEnvironmentVariable($binding[0]) + if (-not [string]::IsNullOrWhiteSpace($current) -and $current -cne [string] $Plan[$binding[1]]) { + throw 'The deployment plan belongs to a different pipeline run.' + } + } +} + +function Get-SampleOwnershipTags { + param([Parameter(Mandatory)] [hashtable] $Plan) + + Assert-SampleRunPlan -Plan $Plan + return @{ + 'purpose' = 'azure-ai-projects-sample-ci' + 'sample-ci-run' = '{0}-{1}-{2}-{3}' -f $Plan.definitionId, $Plan.buildId, $Plan.queueName, $Plan.jobAttempt + 'sample-ci-owner' = $Plan.ownerToken + 'sample-ci-queue' = $Plan.queueName + } +} + +function Assert-SampleResourceGroupOwnership { + param( + [Parameter(Mandatory)] [hashtable] $Plan, + [Parameter(Mandatory)] [hashtable] $ResourceGroup, + [Parameter(Mandatory)] [string] $SubscriptionId + ) + + $expectedId = '/subscriptions/{0}/resourceGroups/{1}' -f $SubscriptionId, $Plan.resourceGroup + if ($Plan['subscriptionId'] -ine $SubscriptionId -or $ResourceGroup['id'] -ine $expectedId -or + $ResourceGroup['tags'] -isnot [System.Collections.IDictionary]) { + throw 'Refusing a resource group outside the deployment state or without ownership tags.' + } + $tags = Get-SampleOwnershipTags -Plan $Plan + foreach ($key in $tags.Keys) { + if ($ResourceGroup.tags[$key] -cne $tags[$key]) { + throw 'Refusing a resource group without all exact CI ownership tags.' + } + } +} + +function Assert-SampleMcpPat { + # No PAT command-line parameter, .env import, or secret-bearing return value. + if ([string] $env:MCP_GITHUB_PAT -notmatch '^(github_pat_[A-Za-z0-9_]+|ghp_[A-Za-z0-9]+)$') { + throw 'Set MCP_GITHUB_PAT as an ADO secret containing the complete GitHub PAT (github_pat_... or ghp_...).' + } +} + +function Get-SampleIdentityResourceGroup { + param([AllowNull()] [AllowEmptyString()] [string] $Name) + + # Azure resource group rules: 1-90 characters, no trailing period. + if ($Name -cnotmatch '\A[A-Za-z0-9_().-]{1,90}\z' -or $Name.EndsWith('.')) { + throw 'Invalid shared identity resource group name; supply the existing resource group that contains the shared identities.' + } + return $Name +} + +function Get-SampleIdentityPurposeFromName { + param([AllowNull()] [string] $Name) + + # Identities created by Deploy-Identities.ps1 carry the purpose as a -{purpose}- name segment. + $found = @(@('project', 'account', 'search', 'function') | Where-Object { $Name -imatch ('-{0}-' -f $_) }) + if ($found.Count -eq 1) { return $found[0] } + return $null +} + +function Assert-SampleResolvedIdentityBindings { + param([hashtable] $Plan) + + if ($Plan['location'] -cnotmatch '\A[a-z][a-z0-9]{0,29}\z' -or + $Plan['identityOwner'] -cnotmatch '\A[a-f0-9]{32}\z' -or + $Plan['sharedIdentities'] -isnot [System.Collections.IDictionary] -or $Plan.sharedIdentities.Count -ne 4 -or + $Plan['identityPrincipalIds'] -isnot [System.Collections.IDictionary] -or $Plan.identityPrincipalIds.Count -ne 4) { + throw 'The deployment plan must bind the four bootstrap-owned shared identities and verified region.' + } + foreach ($purpose in @('project', 'account', 'search', 'function')) { + $id = $Plan.sharedIdentities[$purpose] + $binding = [regex]::Match([string]$id, '(?i)\A/subscriptions/(?[a-f0-9-]{36})/resourceGroups/(?[^/]+)/providers/Microsoft.ManagedIdentity/userAssignedIdentities/(?[^/]+)\z') + if (-not $binding.Success -or $binding.Groups['sub'].Value -ine $Plan.subscriptionId -or + (Get-SampleIdentityPurposeFromName -Name $binding.Groups['name'].Value) -cne $purpose -or + $Plan.identityPrincipalIds[$purpose] -notmatch '\A[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}\z' -or + $Plan.identityPrincipalIds[$purpose] -eq '00000000-0000-0000-0000-000000000000') { throw 'Unexpected shared identity binding.' } + if ($binding.Groups['rg'].Value -ine $Plan.identityResourceGroup) { throw 'All shared identities must belong to the selected identity resource group.' } + } +} + +function Resolve-SampleIdentitySelection { + param([Parameter(Mandatory)] [hashtable] $Plan) + + Assert-SampleRunPlan -Plan $Plan + if ($Plan['identityResolution'] -cne 'Pending') { throw 'Identity selection has already been resolved; do not overwrite its bindings.' } + $identityGroup = Get-SampleIdentityResourceGroup -Name $Plan['identityResourceGroup'] + $path = '/subscriptions/' + $Plan.subscriptionId + '/resourceGroups/' + $identityGroup + '/providers/Microsoft.ManagedIdentity/userAssignedIdentities' + $uri = 'https://management.azure.com' + $path + '?api-version=2023-01-31' + $all = @(); $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + do { + $parsed = [uri] $uri + if ($parsed.Scheme -cne 'https' -or $parsed.Host -ine 'management.azure.com' -or $parsed.Port -ne 443 -or + $parsed.UserInfo -or $parsed.Fragment -or $parsed.AbsolutePath -ine $path -or -not $seen.Add($uri) -or $seen.Count -gt 1000) { + throw 'Unexpected identity metadata pagination target.' + } + $query = @{} + foreach ($part in $parsed.Query.TrimStart('?').Split('&')) { + $pair = $part.Split('=',2); $key = [uri]::UnescapeDataString($pair[0]) + if ($pair.Count -ne 2 -or $key -cnotin @('api-version','$skiptoken','skiptoken') -or $query.ContainsKey($key)) { + throw 'Unexpected identity metadata pagination query.' + } + $query[$key] = [uri]::UnescapeDataString($pair[1]) + } + if ($query['api-version'] -cne '2023-01-31') { throw 'Unexpected identity metadata pagination API version.' } + $page = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $Plan.subscriptionId, '--url', $uri) -Sensitive + if ($page -isnot [System.Collections.IDictionary] -or $page['value'] -isnot [System.Collections.IList] -or + ($page.Contains('error') -and $null -ne $page['error']) -or + ($null -ne $page['nextLink'] -and $page['nextLink'] -isnot [string])) { throw 'Cannot verify identity metadata listing.' } + $all += @($page.value); $uri = [string] $page['nextLink'] + } while ($uri) + $ids = @{}; $principals = @{}; $location = $null; $owner = $null + foreach ($purpose in @('project', 'account', 'search', 'function')) { + $candidates = @($all | Where-Object { $_ -is [System.Collections.IDictionary] -and (Get-SampleIdentityPurposeFromName -Name ([string] $_['name'])) -ceq $purpose }) + if ($candidates.Count -ne 1) { throw "The identity resource group must contain exactly one managed identity with a -$purpose- name segment; missing or duplicate matches are not allowed." } + $identity = $candidates[0] + $expectedId = '/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{2}' -f $Plan.subscriptionId, $identityGroup, $identity['name'] + if ($identity['id'] -ine $expectedId -or $identity['properties'] -isnot [System.Collections.IDictionary] -or + $identity['tags'] -isnot [System.Collections.IDictionary] -or $identity['type'] -ine 'Microsoft.ManagedIdentity/userAssignedIdentities' -or + $identity['properties']['tenantId'] -ine $Plan.tenantId -or $identity['location'] -cnotmatch '\A[a-z][a-z0-9]{0,29}\z' -or + $identity['tags']['purpose'] -cne 'azsdk-sample-identities' -or $identity['tags']['sample-ci-retention'] -cne 'permanent' -or + $identity['tags']['sample-ci-identity'] -cne $purpose -or $identity['tags']['sample-ci-owner'] -cnotmatch '\A[a-f0-9]{32}\z') { + throw 'Cannot verify the bootstrap-owned shared identity metadata.' + } + if ($location -and $location -cne $identity.location) { throw 'All shared identities must have one verified region.' } + if ($owner -and $owner -cne $identity.tags['sample-ci-owner']) { throw 'All shared identities must belong to one bootstrap-owned set.' } + $location = $identity.location; $owner = $identity.tags['sample-ci-owner'] + $ids[$purpose] = $identity.id; $principals[$purpose] = $identity.properties.principalId + } + $resolved = @{} + $Plan + $resolved.identityResolution = 'Resolved'; $resolved.location = $location + $resolved.sharedIdentities = $ids; $resolved.identityPrincipalIds = $principals; $resolved.identityOwner = $owner + Assert-SampleRunPlan -Plan $resolved + return $resolved +} + +function Get-SampleSharedIdentities { + param([Parameter(Mandatory)] [hashtable] $Plan) + + Assert-SampleRunPlan -Plan $Plan + if ($Plan['identityResolution'] -cne 'Resolved') { throw 'Resolve the shared identities from the identity resource group before provisioning.' } + if ($Plan['sharedIdentities'] -isnot [System.Collections.IDictionary] -or $Plan.sharedIdentities.Count -ne 4) { + throw 'The deployment plan must bind the four bootstrap-owned shared identities.' + } + $identities = @{} + $identityOwner = $null + foreach ($purpose in @('project', 'account', 'search', 'function')) { + $expected = $Plan.sharedIdentities[$purpose] + $identity = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $Plan.subscriptionId, '--url', + ('https://management.azure.com{0}?api-version=2023-01-31' -f $expected)) -Sensitive + if ($identity -isnot [System.Collections.IDictionary] -or + $identity['properties'] -isnot [System.Collections.IDictionary] -or $identity['tags'] -isnot [System.Collections.IDictionary] -or + $identity['id'] -ine $expected -or $identity['type'] -ine 'Microsoft.ManagedIdentity/userAssignedIdentities' -or + $identity['location'] -cne $Plan.location -or $identity['properties']['tenantId'] -ine $Plan.tenantId -or + $identity['properties']['principalId'] -notmatch '^[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$' -or + $identity['properties']['principalId'] -eq '00000000-0000-0000-0000-000000000000' -or + $identity['tags']['purpose'] -cne 'azsdk-sample-identities' -or + $identity['tags']['sample-ci-owner'] -cnotmatch '^[a-f0-9]{32}$' -or + $identity['tags']['sample-ci-retention'] -cne 'permanent' -or + $identity['tags']['sample-ci-identity'] -cne $purpose) { + throw 'Cannot verify the bootstrap-owned shared identity; no resources were changed.' + } + if ($identityOwner -and $identityOwner -cne $identity.tags['sample-ci-owner']) { + throw 'The four shared identities do not belong to one bootstrap-owned set.' + } + $identityOwner = $identity.tags['sample-ci-owner'] + if ($Plan.identityOwner -cne $identityOwner -or + $Plan.identityPrincipalIds[$purpose] -ine $identity.properties.principalId) { throw 'The resolved identity generation changed; no resources were changed.' } + $identities[$purpose] = @{ resourceId = $identity.id; principalId = $identity.properties.principalId } + } + return $identities +} + +function Publish-SampleFunctionApp { + param([Parameter(Mandatory)] [hashtable] $Plan, + [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9-]{0,58}[a-zA-Z0-9]$')] [string] $FunctionAppName, + [string] $SourceDirectory = (Join-Path $PSScriptRoot 'function_app')) + + Assert-SampleRunPlan -Plan $Plan + $files = @('function_app.py', 'host.json', 'requirements.txt') + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory $file) -PathType Leaf)) { + throw "Missing Function App source file '$file'; no code was published." + } + } + $zipPath = Join-Path ([IO.Path]::GetTempPath()) ('sample-function-' + [guid]::NewGuid().ToString('N') + '.zip') + try { + $archive = [IO.Compression.ZipFile]::Open($zipPath, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in $files) { + $entry = $archive.CreateEntry($file) + $entry.LastWriteTime = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + $entryStream = $entry.Open() + try { + $sourceStream = [IO.File]::OpenRead((Join-Path $SourceDirectory $file)) + try { $sourceStream.CopyTo($entryStream) } finally { $sourceStream.Dispose() } + } finally { $entryStream.Dispose() } + } + } finally { $archive.Dispose() } + $null = Invoke-SampleAzure -Arguments @('functionapp', 'deployment', 'source', 'config-zip', + '--name', $FunctionAppName, '--resource-group', $Plan.resourceGroup, '--subscription', $Plan.subscriptionId, + '--src', $zipPath, '--build-remote', 'true', '--timeout', '1200') -Sensitive + for ($attempt = 1; $attempt -le 30; $attempt++) { + $functions = @(Invoke-SampleAzure -Arguments @('functionapp', 'function', 'list', '--name', $FunctionAppName, + '--resource-group', $Plan.resourceGroup, '--subscription', $Plan.subscriptionId, '--query', '[].name') -Sensitive) + if (@($functions | Where-Object { ($_ -split '/')[-1] -ceq 'queue_trigger' }).Count -eq 1) { + return + } + if ($attempt -lt 30) { + Write-Host ('Waiting for queue_trigger indexing after publication (check {0}/30).' -f $attempt) + Start-Sleep -Seconds 10 + } + } + throw 'The published Function App did not index queue_trigger after 30 checks; sample execution is disabled.' + } finally { + if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } + } +} + +function Assert-SampleBatchCapacity { + param([Parameter(Mandatory)] [hashtable[]] $Plans) + + if ($Plans.Count -lt 1 -or $Plans.Count -gt 4 -or @($Plans.queueName | Sort-Object -Unique).Count -ne $Plans.Count -or + @($Plans.resourceGroup | Sort-Object -Unique).Count -ne $Plans.Count) { + throw 'A test batch must contain one to four distinct queue plans.' + } + $first = $Plans[0] + foreach ($plan in $Plans) { + Assert-SampleRunPlan -Plan $plan -QueueName $plan.queueName + foreach ($key in @('subscriptionId', 'tenantId', 'definitionId', 'buildId', 'location', 'identityResourceGroup')) { + if ($plan[$key] -cne $first[$key]) { throw 'Test queues must share one explicit target and pipeline run.' } + } + foreach ($purpose in @('project', 'account', 'search', 'function')) { + if ($plan['sharedIdentities'][$purpose] -cne $first['sharedIdentities'][$purpose]) { + throw 'All test queues must use the same shared identity set.' + } + if ($plan.ContainsKey('identityPrincipalIds') -and + $plan.identityPrincipalIds[$purpose] -cne $first.identityPrincipalIds[$purpose]) { throw 'All test queues must use the same verified identity generation.' } + } + if ($plan.ContainsKey('identityOwner') -and $plan.identityOwner -cne $first.identityOwner) { throw 'All test queues must use the same verified identity generation.' } + if (($plan.sampleModel | ConvertTo-Json -Compress) -cne ($first.sampleModel | ConvertTo-Json -Compress)) { + # Compare fields, not Hashtable property ordering. + foreach ($key in @('name', 'version', 'sku', 'capacity')) { + if ($plan.sampleModel[$key] -cne $first.sampleModel[$key]) { throw 'Test queues must use the same sample model configuration.' } + } + } + } + $leftovers = @(Invoke-SampleAzure -Arguments @('group', 'list', '--subscription', $first.subscriptionId, + '--query', "[?tags.purpose=='azure-ai-projects-sample-ci'].{id:id,name:name}" ) -Sensitive) + if ($leftovers.Count -gt 0) { throw 'Temporary sample RGs from another attempt still exist; reconcile cleanup before admitting another batch.' } + Assert-SampleRegionAvailability -SubscriptionId $first.subscriptionId -Location $first.location + $usages = @(Invoke-SampleAzure -Arguments @('cognitiveservices', 'usage', 'list', '--location', $first.location, '--subscription', $first.subscriptionId)) + $null = Get-SampleQuotaAvailable -Usages $usages -UsageName 'AccountCount' -Unit 'Count' -RequiredCapacity $Plans.Count -Description 'batch Foundry account quota' + $searchUsage = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $first.subscriptionId, '--url', + ('https://management.azure.com/subscriptions/{0}/providers/Microsoft.Search/locations/{1}/usages?api-version=2025-05-01' -f $first.subscriptionId, $first.location)) -Sensitive + $null = Get-SampleQuotaAvailable -Usages @($searchUsage['value']) -UsageName 'standard' -Unit 'Count' -RequiredCapacity $Plans.Count -Description 'batch Search S1 quota' + $catalog = @(Invoke-SampleAzure -Arguments @('cognitiveservices', 'model', 'list', '--location', $first.location, '--subscription', $first.subscriptionId)) + $model = Resolve-SampleModel -Catalog $catalog -Name $first.sampleModel.name -Version $first.sampleModel.version -Sku $first.sampleModel.sku -Capacity $first.sampleModel.capacity + $null = Get-SampleQuotaAvailable -Usages $usages -UsageName $model.usageName -RequiredCapacity ($Plans.Count * $model.capacity) -Description 'batch sample-model quota' + Write-Host ('Admission passed for {0} fresh RGs. No cross-run lock or capacity reservation: wait for all cleanup before another run.' -f $Plans.Count) +} + +function Resolve-SampleModel { + param( + [Parameter(Mandatory)] [object[]] $Catalog, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Version, + [Parameter(Mandatory)] [string] $Sku, + [Parameter(Mandatory)] [int] $Capacity + ) + + $candidates = @($Catalog | Where-Object { $_.model.name -ceq $Name -and $_.model.format -eq 'OpenAI' }) + if ($Version -eq 'auto') { + $versions = @($candidates | Where-Object { $_.model.isDefaultVersion } | ForEach-Object { $_.model.version } | Sort-Object -Unique) + if ($versions.Count -ne 1) { + throw "Cannot resolve exactly one default version of '$Name'; set an explicit model version." + } + $Version = $versions[0] + } + $skuDetails = @($candidates | Where-Object { $_.model.version -ceq $Version } | ForEach-Object { $_.model.skus } | + Where-Object { $_.name -ceq $Sku }) + if ($skuDetails.Count -eq 0) { + throw "Model '$Name' version '$Version' with SKU '$Sku' is unavailable in the selected region." + } + $limits = $skuDetails[0]['capacity'] + if ($Capacity -lt 1 -or ($null -ne $limits['minimum'] -and $Capacity -lt $limits['minimum']) -or + ($null -ne $limits['maximum'] -and $Capacity -gt $limits['maximum']) -or + ($null -ne $limits['step'] -and $limits['step'] -gt 0 -and ($Capacity % $limits['step']) -ne 0)) { + throw "Capacity $Capacity does not satisfy model '$Name' SKU limits." + } + return @{ name = $Name; version = $Version; sku = $Sku; capacity = $Capacity; usageName = $skuDetails[0]['usageName'] } +} + +function Get-SampleQuotaAvailable { + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Usages, + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $UsageName, + [Parameter(Mandatory)] [string] $Description, + [ValidateRange(1, 1000000)] [int] $RequiredCapacity = 1, + [string] $Unit = '' + ) + + $quota = @($Usages | Where-Object { + $_ -is [System.Collections.IDictionary] -and $_['name'] -is [System.Collections.IDictionary] -and + $_['name']['value'] -ceq $UsageName + }) + if ($quota.Count -ne 1) { + throw "Cannot verify $Description; no resources were created." + } + $usage = $quota[0] + foreach ($key in @('currentValue', 'limit')) { + $value = $usage[$key] + if (($value -isnot [int] -and $value -isnot [long] -and $value -isnot [double] -and $value -isnot [decimal]) -or + -not [double]::IsFinite([double] $value) -or $value -lt 0) { + throw "Cannot verify $Description; no resources were created." + } + } + if ($Unit -and $usage['unit'] -cne $Unit) { + throw "Cannot verify $Description; no resources were created." + } + $available = $usage.limit - $usage.currentValue + if ($available -lt $RequiredCapacity -or $usage['status'] -in @('Blocked', 'InOverage', 'Unknown')) { + $message = 'Insufficient {0} ({1}: {2}/{3}, status: {4}).' -f ` + $Description, $UsageName, $usage.currentValue, $usage.limit, $usage['status'] + throw ($message + ' No resources were created. Choose a region with available account quota or ask an administrator to review quota; ' + + 'existing accounts will not be deleted.') + } + return $available +} + +function Assert-SampleAccountQuota { + param( + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Usages, + [Parameter(Mandatory)] [string] $Location + ) + + # Account creation has a separate quota from model deployment capacity. + $available = Get-SampleQuotaAvailable -Usages $Usages -UsageName 'AccountCount' -Unit 'Count' ` + -Description "Cognitive Services account quota in '$Location'" + Write-Host ('Account preflight: {0}, AccountCount available slots: {1}.' -f $Location, $available) +} + +function Assert-SampleRegionAvailability { + param( + [Parameter(Mandatory)] [string] $SubscriptionId, + [Parameter(Mandatory)] [ValidatePattern('^[a-z0-9]+$')] [string] $Location + ) + + $subscription = Get-SampleSubscriptionId -SubscriptionId $SubscriptionId + $root = 'https://management.azure.com' + $scope = "$root/subscriptions/$subscription/providers" + $offerings = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $subscription, '--url', + "$root/providers/Microsoft.Search/offerings?api-version=2025-02-01-preview") -Sensitive + $searchRegions = @($offerings['value'] | Where-Object { ($_['regionName'] -replace '\s', '') -ieq $Location }) + if ($searchRegions.Count -ne 1) { throw "Cannot verify AI Search offerings in '$Location'; no resources were created." } + $searchSkus = @($searchRegions[0]['skus'] | Where-Object { + $_['name'] -ceq 'standard' -or ($_['sku'] -and $_['sku']['name'] -ceq 'standard') + }) + if ($searchSkus.Count -ne 1) { throw "AI Search S1 is not offered in '$Location'; no resources were created." } + $searchUsage = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $subscription, '--url', + "$scope/Microsoft.Search/locations/$Location/usages?api-version=2025-05-01") -Sensitive + $null = Get-SampleQuotaAvailable -Usages @($searchUsage['value']) -UsageName 'standard' -Unit 'Count' ` + -Description "AI Search S1 service quota in '$Location'" + + $cosmosLocations = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $subscription, '--url', + "$scope/Microsoft.DocumentDB/locations?api-version=2025-04-15") -Sensitive + $cosmosRegions = @($cosmosLocations['value'] | Where-Object { ($_['name'] -replace '\s', '') -ieq $Location }) + if ($cosmosRegions.Count -ne 1) { throw "Cannot verify Cosmos DB regional access in '$Location'; no resources were created." } + $access = $cosmosRegions[0]['properties']['isSubscriptionRegionAccessAllowedForRegular'] + if ($access -isnot [bool] -or -not $access) { + throw "Cosmos DB account creation is not allowed for this subscription in '$Location'; no resources were created." + } + + # Match the deployed local shared-identity template, not the upstream + # reference's regional GRS fallback. Never approve a different SKU's evidence. + $storageSku = 'Standard_ZRS' + $storageSkus = Invoke-SampleAzure -Arguments @('rest', '--method', 'get', '--subscription', $subscription, '--url', + "$scope/Microsoft.Storage/skus?api-version=2023-05-01") -Sensitive + $storage = @($storageSkus['value'] | Where-Object { + $_['name'] -ceq $storageSku -and $_['kind'] -ceq 'StorageV2' -and + $Location -iin @($_['locations'] | ForEach-Object { $_ -replace '\s', '' }) + }) + if ($storage.Count -ne 1) { throw "Cannot verify StorageV2 $storageSku in '$Location'; no resources were created." } + foreach ($restriction in @($storage[0]['restrictions'] | Where-Object { $null -ne $_ })) { + $restricted = @($restriction['values'] | ForEach-Object { $_ -replace '\s', '' }) + if ($restriction['type'] -ine 'Location' -or $restricted.Count -eq 0 -or $Location -iin $restricted) { + throw "StorageV2 $storageSku is restricted in '$Location'; no resources were created." + } + } + Write-Host ('Regional dependency preflight: {0}, Search S1, Cosmos DB and StorageV2 {1} are eligible.' -f $Location, $storageSku) +} + +function Get-SampleRunnerIdentity { + param([string] $SubscriptionId = '') + + $runnerTokenResponse = $null + $runnerAccessToken = $null + $claims = $null + try { + $tokenArguments = @('account', 'get-access-token', '--resource', 'https://management.azure.com/') + if ($SubscriptionId) { $tokenArguments += @('--subscription', (Get-SampleSubscriptionId -SubscriptionId $SubscriptionId)) } + $runnerTokenResponse = Invoke-SampleAzure -Arguments $tokenArguments -Sensitive + $runnerAccessToken = [string] $runnerTokenResponse.accessToken + $payload = $runnerAccessToken.Split('.')[1].Replace('-', '+').Replace('_', '/') + $payload = $payload.PadRight($payload.Length + ((4 - $payload.Length % 4) % 4), '=') + $claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json -AsHashtable + $clientId = if ($claims['appid']) { [string] $claims['appid'] } else { [string] $claims['azp'] } + if ($claims['oid'] -notmatch '^[a-fA-F0-9-]{36}$' -or $claims['tid'] -notmatch '^[a-fA-F0-9-]{36}$' -or + $clientId -notmatch '^[a-fA-F0-9-]{36}$' -or $claims['idtyp'] -notin @($null, 'app') -or $claims.ContainsKey('scp')) { + throw 'An Azure service connection application identity is required.' + } + # Metadata only: never return or persist the token, authorization headers, or other claims. + return @{ principalId = [string] $claims.oid; clientId = $clientId; tenantId = [string] $claims.tid } + } catch { + throw 'Could not identify the Azure service-connection application; token and raw response were suppressed.' + } finally { + $runnerAccessToken = $null + $runnerTokenResponse = $null + $claims = $null + } +} + +function Save-SampleInventory { + param([Parameter(Mandatory)] [hashtable] $State, [Parameter(Mandatory)] [string] $OutputPath) + + $exists = Invoke-SampleAzure -Arguments @('group', 'exists', '--name', $State.plan.resourceGroup, '--subscription', $State.subscriptionId) + $resources = @() + if ($exists) { + $resources = @(Invoke-SampleAzure -Arguments @('resource', 'list', '--resource-group', $State.plan.resourceGroup, + '--subscription', $State.subscriptionId, '--query', '[].{id:id,name:name,type:type,location:location}')) + } + ConvertTo-Json -InputObject $resources -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +} + +function Get-SampleAzureErrorCodes { + param([AllowNull()] [object] $ErrorDetails) + + # Traverse only ARM error envelopes, never message, request, parameter or credential fields. + $codes = @() + if ($ErrorDetails -is [System.Collections.IDictionary]) { + $code = $ErrorDetails['code'] + if ($code -is [string] -and $code -cmatch '^[A-Za-z][A-Za-z0-9_.-]{0,127}$' -and + $code -notmatch '^(github_pat_|ghp_|eyJ)') { + $codes += $code + } + foreach ($key in @('error', 'details')) { + if ($ErrorDetails.Contains($key)) { $codes += @(Get-SampleAzureErrorCodes -ErrorDetails $ErrorDetails[$key]) } + } + } elseif ($ErrorDetails -is [System.Collections.IEnumerable] -and $ErrorDetails -isnot [string]) { + foreach ($detail in $ErrorDetails) { $codes += @(Get-SampleAzureErrorCodes -ErrorDetails $detail) } + } + return @($codes | Sort-Object -Unique) +} + +function Get-SampleAzureCliErrorCodes { + param([AllowEmptyString()] [string] $Text) + + $codes = @() + foreach ($line in ($Text -split '\r?\n')) { + $json = $line.Trim() -replace '^ERROR:\s*', '' + if ($json.StartsWith('{') -and $json.EndsWith('}')) { + try { + $details = $json | ConvertFrom-Json -AsHashtable -ErrorAction Stop + $codes += @(Get-SampleAzureErrorCodes -ErrorDetails $details) + } catch { } + } elseif ($line -cmatch '^ERROR:\s*\((?[A-Za-z][A-Za-z0-9_.-]{0,127})\)') { + $codes += @(Get-SampleAzureErrorCodes -ErrorDetails @{ code = $Matches.armCode }) + } + } + return @($codes | Sort-Object -Unique) +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/function_app.py b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/function_app.py new file mode 100644 index 000000000000..18613c96bb27 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/function_app.py @@ -0,0 +1,20 @@ +import json + +import azure.functions as func + +app = func.FunctionApp() + + +@app.queue_trigger(arg_name="msg", queue_name="%STORAGE_INPUT_QUEUE_NAME%", connection="STORAGE_CONNECTION") +@app.queue_output(arg_name="output_queue", queue_name="%STORAGE_OUTPUT_QUEUE_NAME%", connection="STORAGE_CONNECTION") +def queue_trigger(msg: func.QueueMessage, output_queue: func.Out[str]) -> None: + payload = json.loads(msg.get_body().decode("utf-8")) + location = payload["function_args"]["location"] + output_queue.set( + json.dumps( + { + "Value": f"Weather is {len(location)} degrees and sunny in {location}", + "CorrelationId": payload["CorrelationId"], + } + ) + ) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/host.json b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/host.json new file mode 100644 index 000000000000..55d16424d66d --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/requirements.txt b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/requirements.txt new file mode 100644 index 000000000000..470562356976 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/function_app/requirements.txt @@ -0,0 +1 @@ +azure-functions>=1.21.3,<2.0.0 \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/ai-search.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/ai-search.bicep new file mode 100644 index 000000000000..8a2c686de576 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/ai-search.bicep @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// cspell:ignore hnsw vectorizer vectorizers + +param location string +param accountName string +param projectName string +param storageAccountName string +param searchServiceName string +param sharedSearchIdentityResourceId string +param ownerToken string + +@minLength(1) +param indexName string = 'index-sample' +@minLength(3) +param containerName string = 'documents' + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: storageAccountName +} +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' existing = { + parent: storage + name: 'default' +} +resource documents 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: containerName + properties: { + publicAccess: 'None' + } +} +resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { + name: accountName +} +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-preview' existing = { + parent: account + name: projectName +} +resource search 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { + name: searchServiceName +} +resource embedding 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = { + parent: account + name: 'text-embedding-3-small' + sku: { + name: 'GlobalStandard' + capacity: 10 + } + properties: { + model: { + format: 'OpenAI' + name: 'text-embedding-3-small' + version: '1' + } + versionUpgradeOption: 'NoAutoUpgrade' + } +} + +var dimensions = 1536 +var searchEndpoint = 'https://${search.name}.search.windows.net' +var embeddingEndpoint = 'https://${account.name}.openai.azure.com' +var datasourceName = '${indexName}-datasource' +var skillsetName = '${indexName}-skillset' +var searchIdentity = { + '@odata.type': '#Microsoft.Azure.Search.DataUserAssignedIdentity' + userAssignedIdentity: sharedSearchIdentityResourceId +} +var embeddingParameters = { + resourceUri: embeddingEndpoint + deploymentId: embedding.name + modelName: 'text-embedding-3-small' + authIdentity: searchIdentity +} +var index = { + name: indexName + fields: [ + { name: 'chunk_id', type: 'Edm.String', key: true, searchable: true, analyzer: 'keyword' } + { name: 'parent_id', type: 'Edm.String', filterable: true } + { name: 'chunk', type: 'Edm.String', searchable: true, retrievable: true } + { name: 'title', type: 'Edm.String', retrievable: true } + { + name: 'text_vector' + type: 'Collection(Edm.Single)' + searchable: true + dimensions: dimensions + vectorSearchProfile: 'embedding_config' + } + ] + vectorSearch: { + algorithms: [{ name: 'embed-algorithms-config', kind: 'hnsw' }] + profiles: [{ + name: 'embedding_config' + algorithm: 'embed-algorithms-config' + vectorizer: 'search_vectorizer' + }] + vectorizers: [{ + name: 'search_vectorizer' + kind: 'azureOpenAI' + azureOpenAIParameters: embeddingParameters + }] + } + semantic: { + defaultConfiguration: 'semantic_search' + configurations: [{ + name: 'semantic_search' + prioritizedFields: { + titleField: { fieldName: 'title' } + prioritizedContentFields: [{ fieldName: 'chunk' }] + } + }] + } +} +var skillset = { + name: skillsetName + skills: [ + { + '@odata.type': '#Microsoft.Skills.Text.SplitSkill' + name: 'document_chunking_skill' + context: '/document' + textSplitMode: 'pages' + maximumPageLength: 2000 + pageOverlapLength: 500 + inputs: [{ name: 'text', source: '/document/content' }] + outputs: [{ name: 'textItems', targetName: 'pages' }] + } + { + '@odata.type': '#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill' + name: 'embedding_generation_skill' + context: '/document/pages/*' + resourceUri: embeddingEndpoint + deploymentId: embedding.name + modelName: 'text-embedding-3-small' + authIdentity: searchIdentity + dimensions: dimensions + inputs: [{ name: 'text', source: '/document/pages/*' }] + outputs: [{ name: 'embedding', targetName: 'text_vector' }] + } + ] + indexProjections: { + selectors: [{ + targetIndexName: indexName + parentKeyFieldName: 'parent_id' + sourceContext: '/document/pages/*' + mappings: [ + { name: 'text_vector', source: '/document/pages/*/text_vector' } + { name: 'chunk', source: '/document/pages/*' } + { name: 'title', source: '/document/title' } + ] + }] + parameters: { projectionMode: 'skipIndexingParentDocuments' } + } +} +var indexers = [for format in [ + { suffix: 'markdown', extensions: '.md', parsingMode: 'markdown' } + { suffix: 'documents', extensions: '.pdf,.docx,.pptx,.xlsx,.txt', parsingMode: 'default' } +]: { + name: '${indexName}-${format.suffix}-indexer' + dataSourceName: datasourceName + targetIndexName: indexName + skillsetName: skillsetName + fieldMappings: [{ sourceFieldName: 'metadata_storage_name', targetFieldName: 'title' }] + parameters: { + batchSize: 10 + maxFailedItems: 0 + maxFailedItemsPerBatch: 0 + configuration: { + parsingMode: format.parsingMode + indexedFileNameExtensions: format.extensions + allowSkillsetToReadFileData: format.parsingMode == 'default' + } + } +}] + +// Search objects and blob uploads are data-plane operations, not ARM resources. +// The existing Search UAMI has bootstrap-owned grants; this module writes no IAM. +resource indexing 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: 'ai-search-indexing' + location: location + kind: 'AzureCLI' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sharedSearchIdentityResourceId}': {} + } + } + properties: { + azCliVersion: '2.76.0' + timeout: 'PT30M' + retentionInterval: 'P1D' + cleanupPreference: 'Always' + forceUpdateTag: ownerToken + environmentVariables: [{ + name: 'SEARCH_INDEXING_CONFIGURATION' + value: string({ + searchEndpoint: searchEndpoint + storageEndpoint: storage.properties.primaryEndpoints.blob + containerName: documents.name + blobs: [ + { name: 'product_info.md', content: loadTextContent('../search_documents/product_info.md') } + { name: 'product_info.txt', content: loadTextContent('../search_documents/product_info.md') } + ] + index: index + datasource: { + name: datasourceName + type: 'azureblob' + credentials: { connectionString: 'ResourceId=${storage.id};' } + container: { name: documents.name } + identity: searchIdentity + } + skillset: skillset + indexers: indexers + }) + }] + scriptContent: format('''set -euo pipefail +python3 - <<'PY' +{0} +PY +''', loadTextContent('./search-indexing.py')) + } +} + +output environment object = { + AZURE_AI_SEARCH_ENDPOINT: searchEndpoint + AI_SEARCH_PROJECT_CONNECTION_ID: '${project.id}/connections/${search.name}' + AI_SEARCH_INDEX_NAME: indexName + AZURE_AI_EMBED_DEPLOYMENT_NAME: embedding.name + AZURE_AI_EMBED_DIMENSIONS: string(dimensions) + AZURE_BLOB_CONTAINER_NAME: documents.name + AI_SEARCH_USER_INPUT: 'What features do Contoso SmartView Glasses offer?' +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-custom-search.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-custom-search.bicep new file mode 100644 index 000000000000..4a5284816f87 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-custom-search.bicep @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param accountName string +param projectName string +param bingAccountName string + +// Microsoft.Bing types are absent from the bundled Bicep catalog; matches the pool's ARM request. +#disable-next-line BCP081 +resource bing 'Microsoft.Bing/accounts@2020-06-10' = { + name: bingAccountName + location: 'global' + kind: 'Bing.GroundingCustomSearch' + sku: { + name: 'G2' + } + properties: { + statisticsEnabled: false + } +} + +#disable-next-line BCP081 +resource configuration 'Microsoft.Bing/accounts/customSearchConfigurations@2025-05-01-preview' = { + parent: bing + name: 'agentdoc' + properties: { + allowedDomains: [ + { + domain: 'https://learn.microsoft.com/en-us/azure/ai-foundry/agents/overview' + includeSubPages: false + boostLevel: 'Default' + } + { + domain: 'https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/overview' + includeSubPages: false + boostLevel: 'Default' + } + { + domain: 'https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-grounding' + includeSubPages: false + boostLevel: 'Default' + } + { + domain: 'https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-custom-search' + includeSubPages: false + boostLevel: 'Default' + } + ] + blockedDomains: [] + pinnedDomains: [] + } +} + +resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-preview' existing = { + parent: account + name: projectName +} + +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-10-01-preview' = { + parent: project + name: 'bing-custom' + properties: { + category: 'GroundingWithCustomSearch' + target: bing.properties.endpoint + authType: 'ApiKey' + isSharedToAll: false + credentials: { + key: bing.listKeys().key1 + } + metadata: { + ApiType: 'Azure' + ResourceId: bing.id + type: 'bing_custom_search' + } + } +} + +output environment object = { + BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID: connection.id + BING_CUSTOM_SEARCH_PROJECT_CONNECTION_NAME: connection.name + BING_CUSTOM_SEARCH_INSTANCE_NAME: configuration.name + BING_CUSTOM_USER_INPUT: 'What is Azure AI Foundry Agent Service and which search tools does it support?' +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-search.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-search.bicep new file mode 100644 index 000000000000..c11c35696bca --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/bing-search.bicep @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param accountName string +param projectName string +param bingAccountName string + +// Microsoft.Bing types are absent from the bundled Bicep catalog; matches the pool's ARM request. +#disable-next-line BCP081 +resource bing 'Microsoft.Bing/accounts@2020-06-10' = { + name: bingAccountName + location: 'global' + kind: 'Bing.Grounding' + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } +} + +resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-preview' existing = { + parent: account + name: projectName +} + +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-10-01-preview' = { + parent: project + name: 'bing' + properties: { + category: 'GroundingWithBingSearch' + target: bing.properties.endpoint + authType: 'ApiKey' + isSharedToAll: false + credentials: { + key: bing.listKeys().key1 + } + metadata: { + ApiType: 'Azure' + ResourceId: bing.id + type: 'bing_grounding' + } + } +} + +output environment object = { + BING_PROJECT_CONNECTION_ID: connection.id + BING_PROJECT_CONNECTION_NAME: connection.name +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/browser-automation.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/browser-automation.bicep new file mode 100644 index 000000000000..6831cea7ab6b --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/browser-automation.bicep @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// Ports the pool's workspace and connection scripts using the shared project UAMI. + +param location string +@minLength(2) +param accountName string +@minLength(2) +param projectName string +@minLength(3) +@maxLength(24) +param workspaceName string + +resource workspace 'Microsoft.LoadTestService/playwrightWorkspaces@2025-09-01' = { + name: workspaceName + location: location + properties: { + localAuth: 'Disabled' + regionalAffinity: 'Enabled' + } +} + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + parent: account + name: projectName +} + +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: project + name: 'browserAutomation' + properties: { + category: 'PlaywrightWorkspace' + target: '${replace(workspace.properties.dataplaneUri, '.api.playwright.microsoft.com', '.mcp.playwright.microsoft.com')}/mcp' + // Foundry's Playwright sample uses this value; the API's published Bicep enum omits it. + #disable-next-line BCP036 + authType: 'ProjectManagedIdentity' + audience: 'https://mcp.playwright.microsoft.com' + isSharedToAll: true + credentials: null + metadata: { + resourceId: workspace.id + } + } +} + +output environment object = { + BROWSER_AUTOMATION_PROJECT_CONNECTION_ID: connection.id + BROWSER_AUTOMATION_PROJECT_CONNECTION_NAME: connection.name + PLAYWRIGHT_SERVICE_RESOURCE_ID: workspace.id +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/function-app.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/function-app.bicep new file mode 100644 index 000000000000..af3909d7fa9d --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/function-app.bicep @@ -0,0 +1,130 @@ +targetScope = 'resourceGroup' + +@description('Region of the existing storage account and shared Function identity.') +@minLength(1) +param location string + +@minLength(2) +@maxLength(60) +param functionAppName string + +@description('Linux Elastic Premium plan name, stable across redeployments.') +param planName string = 'plan-${uniqueString(resourceGroup().id, functionAppName)}' + +@allowed([ + 'EP1' + 'EP2' + 'EP3' +]) +param planSku string = 'EP1' + +@description('Python runtime version used by the separately deployed function code.') +@minLength(1) +param runtimeVersion string = '3.11' + +@description('Existing storage account in this resource group. Shared-key access is not required.') +@minLength(3) +@maxLength(24) +param storageAccountName string + +@description('Full resource ID of the existing Function UAMI with preconfigured storage permissions. No IAM grants are created.') +@minLength(1) +param sharedFunctionIdentityResourceId string + +@minLength(3) +@maxLength(63) +param inputQueueName string = 'weather-input-queue' + +@minLength(3) +@maxLength(63) +param outputQueueName string = 'weather-output-queue' + +var identityParts = split(sharedFunctionIdentityResourceId, '/') + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + scope: resourceGroup(identityParts[2], identityParts[4]) + name: identityParts[8] +} + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: storageAccountName +} + +resource queueService 'Microsoft.Storage/storageAccounts/queueServices@2023-05-01' existing = { + parent: storage + name: 'default' +} + +resource queues 'Microsoft.Storage/storageAccounts/queueServices/queues@2023-05-01' = [for queueName in union([inputQueueName, outputQueueName], []): { + parent: queueService + name: queueName +}] + +var queueEndpoint = storage.properties.primaryEndpoints.queue +var queueServiceUri = endsWith(queueEndpoint, '/') ? substring(queueEndpoint, 0, max(0, length(queueEndpoint) - 1)) : queueEndpoint + +resource plan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: planName + location: location + kind: 'linux' + sku: { + name: planSku + tier: 'ElasticPremium' + } + properties: { + reserved: true + } +} + +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: functionAppName + location: location + kind: 'functionapp,linux' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sharedFunctionIdentityResourceId}': {} + } + } + properties: { + serverFarmId: plan.id + httpsOnly: true + reserved: true + keyVaultReferenceIdentity: sharedFunctionIdentityResourceId + siteConfig: { + linuxFxVersion: 'Python|${runtimeVersion}' + minTlsVersion: '1.2' + ftpsState: 'Disabled' + http20Enabled: true + alwaysOn: true + } + } +} + +resource appSettings 'Microsoft.Web/sites/config@2023-12-01' = { + parent: functionApp + name: 'appsettings' + properties: { + AzureWebJobsStorage__accountName: storage.name + AzureWebJobsStorage__credential: 'managedidentity' + AzureWebJobsStorage__clientId: identity.properties.clientId + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: 'python' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + STORAGE_INPUT_QUEUE_NAME: inputQueueName + STORAGE_OUTPUT_QUEUE_NAME: outputQueueName + STORAGE_CONNECTION__queueServiceUri: queueServiceUri + STORAGE_CONNECTION__credential: 'managedidentity' + STORAGE_CONNECTION__clientId: identity.properties.clientId + } +} + +output functionAppResourceId string = functionApp.id +output environment object = { + FUNCTION_APP_NAME: functionApp.name + FUNCTION_APP_URL: 'https://${functionApp.properties.defaultHostName}' + STORAGE_INPUT_QUEUE_NAME: inputQueueName + STORAGE_OUTPUT_QUEUE_NAME: outputQueueName + STORAGE_QUEUE_SERVICE_ENDPOINT: queueServiceUri +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/main.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/main.bicep new file mode 100644 index 000000000000..1aa603cb21de --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/main.bicep @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +targetScope = 'resourceGroup' + +param location string +param accountPrefix string +param deploymentTimestamp string +param ownerToken string +param agentName string +param sampleModel object +param sharedProjectIdentityResourceId string +param sharedAccountIdentityResourceId string +param sharedSearchIdentityResourceId string +param sharedFunctionIdentityResourceId string +param sharedProjectPrincipalId string + +@secure() +param mcpPat string + +@secure() +@minLength(1) +param openApiKey string + +// All modules are unconditional. Test selection is not an infrastructure input. +module standard './standard-shared-identities.bicep' = { + name: 'standard-setup' + params: { + location: location + accountPrefix: accountPrefix + projectPrefix: 'project' + deploymentTimestamp: deploymentTimestamp + sharedProjectIdentityResourceId: sharedProjectIdentityResourceId + sharedAccountIdentityResourceId: sharedAccountIdentityResourceId + sharedSearchIdentityResourceId: sharedSearchIdentityResourceId + sharedProjectPrincipalId: sharedProjectPrincipalId + } +} + +module samples './sample-resources.bicep' = { + name: 'sample-resources' + params: { + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + agentName: agentName + sampleModel: sampleModel + mcpPat: mcpPat + } +} + +module functions './function-app.bicep' = { + name: 'function-app' + params: { + location: location + functionAppName: 'func-${substring(ownerToken, 0, 20)}' + storageAccountName: standard.outputs.storageAccountName + sharedFunctionIdentityResourceId: sharedFunctionIdentityResourceId + } +} + +module search './ai-search.bicep' = { + name: 'ai-search' + params: { + location: location + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + storageAccountName: standard.outputs.storageAccountName + searchServiceName: standard.outputs.searchServiceName + sharedSearchIdentityResourceId: sharedSearchIdentityResourceId + ownerToken: ownerToken + } + // Foundry rejects concurrent model deployment operations on the same account. + dependsOn: [ + samples + ] +} + +module browser './browser-automation.bicep' = { + name: 'browser-automation' + params: { + location: location + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + workspaceName: 'pw-${uniqueString(resourceGroup().id, ownerToken)}' + } +} + +module bing './bing-search.bicep' = { + name: 'bing-search' + params: { + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + bingAccountName: 'bing-${uniqueString(resourceGroup().id, ownerToken)}' + } +} + +module bingCustom './bing-custom-search.bicep' = { + name: 'bing-custom-search' + params: { + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + bingAccountName: 'bing-custom-${uniqueString(resourceGroup().id, ownerToken)}' + } +} + +module openapi './openapi.bicep' = { + name: 'openapi' + params: { + accountName: standard.outputs.accountName + projectName: standard.outputs.projectName + openApiKey: openApiKey + } +} + +// Public, non-secret contract consumed by every SDK language. Keep explicit keys +// so offline planning can discover supported bindings without Azure or a compiler. +// Add new modules above and expose their test inputs here; no PowerShell edits. +output environment object = { + FOUNDRY_PROJECT_ENDPOINT: samples.outputs.environment.FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL_NAME: samples.outputs.environment.FOUNDRY_MODEL_NAME + FOUNDRY_AGENT_NAME: samples.outputs.environment.FOUNDRY_AGENT_NAME + MCP_PROJECT_CONNECTION_ID: samples.outputs.environment.MCP_PROJECT_CONNECTION_ID + FUNCTION_APP_NAME: functions.outputs.environment.FUNCTION_APP_NAME + FUNCTION_APP_URL: functions.outputs.environment.FUNCTION_APP_URL + STORAGE_INPUT_QUEUE_NAME: functions.outputs.environment.STORAGE_INPUT_QUEUE_NAME + STORAGE_OUTPUT_QUEUE_NAME: functions.outputs.environment.STORAGE_OUTPUT_QUEUE_NAME + STORAGE_QUEUE_SERVICE_ENDPOINT: functions.outputs.environment.STORAGE_QUEUE_SERVICE_ENDPOINT + AZURE_AI_SEARCH_ENDPOINT: search.outputs.environment.AZURE_AI_SEARCH_ENDPOINT + AI_SEARCH_PROJECT_CONNECTION_ID: search.outputs.environment.AI_SEARCH_PROJECT_CONNECTION_ID + AI_SEARCH_INDEX_NAME: search.outputs.environment.AI_SEARCH_INDEX_NAME + AZURE_AI_EMBED_DEPLOYMENT_NAME: search.outputs.environment.AZURE_AI_EMBED_DEPLOYMENT_NAME + AZURE_AI_EMBED_DIMENSIONS: search.outputs.environment.AZURE_AI_EMBED_DIMENSIONS + AZURE_BLOB_CONTAINER_NAME: search.outputs.environment.AZURE_BLOB_CONTAINER_NAME + AI_SEARCH_USER_INPUT: search.outputs.environment.AI_SEARCH_USER_INPUT + BROWSER_AUTOMATION_PROJECT_CONNECTION_ID: browser.outputs.environment.BROWSER_AUTOMATION_PROJECT_CONNECTION_ID + BROWSER_AUTOMATION_PROJECT_CONNECTION_NAME: browser.outputs.environment.BROWSER_AUTOMATION_PROJECT_CONNECTION_NAME + PLAYWRIGHT_SERVICE_RESOURCE_ID: browser.outputs.environment.PLAYWRIGHT_SERVICE_RESOURCE_ID + BING_PROJECT_CONNECTION_ID: bing.outputs.environment.BING_PROJECT_CONNECTION_ID + BING_PROJECT_CONNECTION_NAME: bing.outputs.environment.BING_PROJECT_CONNECTION_NAME + BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID: bingCustom.outputs.environment.BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID + BING_CUSTOM_SEARCH_PROJECT_CONNECTION_NAME: bingCustom.outputs.environment.BING_CUSTOM_SEARCH_PROJECT_CONNECTION_NAME + BING_CUSTOM_SEARCH_INSTANCE_NAME: bingCustom.outputs.environment.BING_CUSTOM_SEARCH_INSTANCE_NAME + BING_CUSTOM_USER_INPUT: bingCustom.outputs.environment.BING_CUSTOM_USER_INPUT + OPENAPI_PROJECT_CONNECTION_ID: openapi.outputs.environment.OPENAPI_PROJECT_CONNECTION_ID + OPENAPI_PROJECT_CONNECTION_NAME: openapi.outputs.environment.OPENAPI_PROJECT_CONNECTION_NAME + TRIPADVISOR_PROJECT_CONNECTION_ID: openapi.outputs.environment.TRIPADVISOR_PROJECT_CONNECTION_ID +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/openapi.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/openapi.bicep new file mode 100644 index 000000000000..53534ccb65a3 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/openapi.bicep @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param accountName string +param projectName string + +@secure() +@minLength(1) +param openApiKey string + +resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-preview' existing = { + parent: account + name: projectName +} + +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-10-01-preview' = { + parent: project + name: 'openapi' + properties: { + category: 'CustomKeys' + target: '_' + authType: 'CustomKeys' + isSharedToAll: false + credentials: { + keys: { + key: openApiKey + } + } + metadata: { + type: 'openapi' + } + } +} + +output environment object = { + OPENAPI_PROJECT_CONNECTION_ID: connection.id + OPENAPI_PROJECT_CONNECTION_NAME: connection.name + TRIPADVISOR_PROJECT_CONNECTION_ID: connection.id +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/resource-group.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/resource-group.bicep new file mode 100644 index 000000000000..f32fd9465888 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/resource-group.bicep @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +targetScope = 'subscription' + +param location string +param resourceGroupName string +param runId string +param queueName string +param ownerToken string +param expiresUtc string + +resource testResources 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: { + purpose: 'azure-ai-projects-sample-ci' + 'sample-ci-run': runId + 'sample-ci-queue': queueName + 'sample-ci-owner': ownerToken + 'expires-utc': expiresUtc + } +} + +output resourceGroupId string = testResources.id diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicep new file mode 100644 index 000000000000..5f513f5c1235 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicep @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +type ModelDeployment = { + @minLength(1) + name: string + @minLength(1) + version: string + @minLength(1) + sku: string + @minValue(1) + capacity: int +} + +@minLength(2) +param accountName string +@minLength(2) +param projectName string +@minLength(1) +param agentName string +param sampleModel ModelDeployment + +@secure() +@description('Complete GitHub PAT, including its prefix. Supplied only through an ADO secret.') +param mcpPat string = '' + +resource account 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-10-01-preview' existing = { + parent: account + name: projectName +} + +resource sampleDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview' = { + parent: account + name: sampleModel.name + sku: { + name: sampleModel.sku + capacity: sampleModel.capacity + } + properties: { + model: { + format: 'OpenAI' + name: sampleModel.name + version: sampleModel.version + } + versionUpgradeOption: 'NoAutoUpgrade' + } +} + +// Same contract as foundry-project-pool/provisions/provision-mcp-connection.ps1. +resource mcpConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-10-01-preview' = { + parent: project + name: 'mcp' + properties: { + category: 'RemoteTool' + authType: 'CustomKeys' + target: 'https://api.githubcopilot.com/mcp' + isSharedToAll: false + credentials: { + keys: { + Authorization: 'Bearer ${mcpPat}' + } + } + metadata: { + type: 'custom_MCP' + } + } +} + +output environment object = { + FOUNDRY_PROJECT_ENDPOINT: 'https://${accountName}.services.ai.azure.com/api/projects/${projectName}' + FOUNDRY_MODEL_NAME: sampleDeployment.name + FOUNDRY_AGENT_NAME: agentName + MCP_PROJECT_CONNECTION_ID: mcpConnection.id +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicepparam b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicepparam new file mode 100644 index 000000000000..41f4777cf47b --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/sample-resources.bicepparam @@ -0,0 +1,4 @@ +using none + +param mcpPat = readEnvironmentVariable('MCP_GITHUB_PAT', '') +param openApiKey = readEnvironmentVariable('PROJ_CONN_OPEN_API_KEY', '') diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/search-indexing.py b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/search-indexing.py new file mode 100644 index 000000000000..50a54e45eb86 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/search-indexing.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Execute the data-plane definitions supplied by the Bicep deployment script.""" + +from datetime import datetime, timezone +import email.utils +import json +import os +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request + +SEARCH_API_VERSION = "2026-04-01" +SEARCH_AUDIENCE = "https://search.azure.com" +STORAGE_AUDIENCE = "https://storage.azure.com/" + + +class RejectRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise RuntimeError("Data-plane redirects are not allowed to forward managed identity tokens.") + + +class DataPlaneClient: + def __init__(self): + self._tokens = {} + self._opener = urllib.request.build_opener(RejectRedirects()) + + def _token(self, audience): + now = time.monotonic() + cached = self._tokens.get(audience) + if cached is not None and now - cached[0] < 300: + return cached[1] + result = subprocess.run( + ["az", "account", "get-access-token", "--resource", audience, "--query", "accessToken", "--output", "tsv"], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + if result.returncode or not result.stdout.strip(): + raise RuntimeError("Managed identity token acquisition failed; command output suppressed.") + token = result.stdout.strip() + self._tokens[audience] = (now, token) + return token + + def request(self, method, url, audience, body=None, headers=None): + parsed = urllib.parse.urlsplit(url) + suffix = {SEARCH_AUDIENCE: ".search.windows.net", STORAGE_AUDIENCE: ".blob.core.windows.net"}.get(audience) + if ( + suffix is None + or parsed.scheme != "https" + or not parsed.hostname + or not parsed.hostname.endswith(suffix) + or parsed.username is not None + or parsed.port not in (None, 443) + ): + raise ValueError("Data-plane endpoint does not match its public Azure token audience.") + data = json.dumps(body).encode("utf-8") if isinstance(body, dict) else body + deadline = time.monotonic() + 600 + while True: + request_headers = { + "Authorization": f"Bearer {self._token(audience)}", + "Content-Type": "application/json", + **(headers or {}), + } + request = urllib.request.Request(url, data=data, method=method, headers=request_headers) + try: + with self._opener.open(request, timeout=60) as response: + content = response.read() + return json.loads(content) if content else None + except urllib.error.HTTPError as error: + status = error.code + error.close() + retry_running = status == 409 and method == "POST" and parsed.path.endswith(("/reset", "/run")) + if (status in (401, 403, 429, 500, 502, 503, 504) or retry_running) and time.monotonic() < deadline: + self._tokens.pop(audience, None) + print(f"Data-plane {method} returned HTTP {status}; retrying within the readiness deadline.") + time.sleep(10) + continue + raise RuntimeError( + f"Data-plane {method} failed with HTTP {status}; response body suppressed." + ) from None + + +def search_url(endpoint, path): + return f"{endpoint.rstrip('/')}/{path}?api-version={SEARCH_API_VERSION}" + + +def wait_for_indexer(client, endpoint, name, previous_start): + url = search_url(endpoint, f"indexers/{urllib.parse.quote(name, safe='')}/status") + deadline = time.monotonic() + 600 + while time.monotonic() < deadline: + status = client.request("GET", url, SEARCH_AUDIENCE) + result = status.get("lastResult") + if result is not None and result.get("startTime") != previous_start: + state = result["status"] + if state == "success": + if result.get("itemsFailed", 0) or result.get("errors") or result.get("warnings"): + raise RuntimeError(f"Indexer '{name}' completed with document failures or enrichment warnings.") + if result.get("itemsProcessed", 0) < 1: + raise RuntimeError(f"Indexer '{name}' completed without processing any documents.") + print(f"Indexer '{name}' succeeded and processed {result['itemsProcessed']} documents.") + return + if state not in ("inProgress", "reset"): + raise RuntimeError(f"Indexer '{name}' failed with status '{state}'; result details suppressed.") + time.sleep(10) + raise TimeoutError(f"Indexer '{name}' did not finish within ten minutes.") + + +def provision(configuration, client): + endpoint = configuration["searchEndpoint"] + for blob in configuration["blobs"]: + path = "/".join(urllib.parse.quote(part, safe="") for part in (configuration["containerName"], blob["name"])) + client.request( + "PUT", + f"{configuration['storageEndpoint'].rstrip('/')}/{path}", + STORAGE_AUDIENCE, + blob["content"].encode("utf-8"), + { + "Content-Type": "text/plain; charset=utf-8", + "x-ms-blob-type": "BlockBlob", + "x-ms-version": "2023-11-03", + "x-ms-date": email.utils.format_datetime(datetime.now(timezone.utc), usegmt=True), + }, + ) + print(f"Uploaded indexing document '{blob['name']}'.") + for collection, definition in ( + ("indexes", configuration["index"]), + ("datasources", configuration["datasource"]), + ("skillsets", configuration["skillset"]), + ): + client.request( + "PUT", + search_url(endpoint, f"{collection}/{urllib.parse.quote(definition['name'], safe='')}"), + SEARCH_AUDIENCE, + definition, + ) + print(f"Provisioned Search {collection}.") + for indexer in configuration["indexers"]: + name = urllib.parse.quote(indexer["name"], safe="") + client.request("PUT", search_url(endpoint, f"indexers/{name}"), SEARCH_AUDIENCE, indexer) + status = client.request("GET", search_url(endpoint, f"indexers/{name}/status"), SEARCH_AUDIENCE) + previous = status.get("lastResult") or {} + client.request("POST", search_url(endpoint, f"indexers/{name}/reset"), SEARCH_AUDIENCE) + client.request("POST", search_url(endpoint, f"indexers/{name}/run"), SEARCH_AUDIENCE) + wait_for_indexer(client, endpoint, indexer["name"], previous.get("startTime")) + result = client.request( + "POST", + search_url(endpoint, f"indexes/{urllib.parse.quote(configuration['index']['name'], safe='')}/docs/search"), + SEARCH_AUDIENCE, + {"search": "*", "top": 1, "select": "chunk"}, + ) + if not result["value"] or not result["value"][0].get("chunk"): + raise RuntimeError("Search index is empty or has no searchable document content.") + print("Azure AI Search index is populated and searchable.") + + +if __name__ == "__main__": + provision(json.loads(os.environ["SEARCH_INDEXING_CONFIGURATION"]), DataPlaneClient()) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-setup.lock.json b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-setup.lock.json new file mode 100644 index 000000000000..6fc35067c488 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-setup.lock.json @@ -0,0 +1,21 @@ +{ + "repository": "microsoft-foundry/foundry-samples", + "commit": "fbbe89b701c758e871922adaceea0ac0e4dae40f", + "path": "infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization", + "files": { + "main.bicep": "6219ab326d14d96a89f9a7801be89c4882be339ac23e94060d3b1af03903da37", + "modules-standard/validate-existing-resources.bicep": "7b6ee940a173e86cf834538d6353c0089486b0ef229d7a55ff762688a0a3f48b", + "modules-standard/standard-dependent-resources.bicep": "b2df5832a11c757135df068a3b0d5c517069e6d4df8b549cb496a9876a8cadd5", + "modules-standard/ai-account-identity.bicep": "d917395d0b972d96c4ed594e35c836b8febd68a042ef3214b476b7ee3cb888a8", + "modules-standard/ai-project-identity.bicep": "0220ed1406abd8a4e4f133122fc59665a10e59aa82fd564ad4f9ac618e6c57fe", + "modules-standard/format-project-workspace-id.bicep": "257ee1db2c33660f3a66d5eca78cc2969795eb4e512777244fda97223a3d8dbe", + "modules-standard/application-insights.bicep": "44ecb5a5508b640cec90b1cb7bf829f4a9b72175790ba8b87cc1e47f9247820c", + "modules-standard/application-insights-role-assignment.bicep": "e2e50c693df5a2304ac99f3f848fe3a15ef231c26310f76e81a0e871e3900fcc", + "modules-standard/azure-storage-account-role-assignment.bicep": "21974374f925c3222fb3ffef6f259cf75bb61d37d43aaa8dcf01b7e585051d69", + "modules-standard/cosmosdb-account-role-assignment.bicep": "d568c3ecea1da6dec298cda1dfde08cd16b80976d860e52d4e67b63d0d479e8a", + "modules-standard/ai-search-role-assignments.bicep": "f7fba74a88f55611102f11de644fde0d684309583adefcd9e6af75e00de03f57", + "modules-standard/add-project-capability-host.bicep": "09c8d966f9f8efe7cb7603219236a73654b7b587d01050fd0dd3e885b85b1ed5", + "modules-standard/blob-storage-container-role-assignments.bicep": "73d1105c27be8c36752a4cb457cc292e9630ad06b77c7888a7b0f1e8b76d0b5f", + "modules-standard/cosmos-container-role-assignments.bicep": "eb3b43de72a3e4d5e743497bdf0e16bec39053a9c868aeb13f34b04e0705e3b7" + } +} diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-shared-identities.bicep b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-shared-identities.bicep new file mode 100644 index 000000000000..9c81a2b0c0b8 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/infra/standard-shared-identities.bicep @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Reviewed local variant of microsoft-foundry/foundry-samples at +// fbbe89b701c758e871922adaceea0ac0e4dae40f: +// infrastructure/infrastructure-setup-bicep/43-standard-agent-setup-with-customization. +// Keep the 14 hash-pinned official sources unchanged and compile them separately. +// This MCP-only variant creates fresh dependencies, attaches pre-existing shared +// identities, and relies on the six project service roles pregranted by bootstrap. +// The pipeline's Foundry User grant is also bootstrap-owned. No ARM IAM writes, +// new identities, Application Insights, Function integration, or models belong here. + +targetScope = 'resourceGroup' + +@description('Region bound to the selected shared MI set and verified by live preflight; no regional fallback.') +@minLength(1) +param location string + +@description('Foundry account prefix, followed by the full 13-character run hash.') +@minLength(1) +@maxLength(9) +param accountPrefix string = 'foundy' + +@description('Foundry project prefix, followed by the same 13-character run hash.') +@minLength(1) +param projectPrefix string = 'project' + +@description('Explicit run timestamp, preserved on retry. Never generate a new value inside this template.') +@minLength(1) +param deploymentTimestamp string + +@description('Full resource ID of the existing, bootstrap-owned project UAMI. The caller must verify its binding to sharedProjectPrincipalId.') +@minLength(1) +param sharedProjectIdentityResourceId string + +@description('Full resource ID of the existing, bootstrap-owned Foundry account UAMI.') +@minLength(1) +param sharedAccountIdentityResourceId string + +@description('Full resource ID of the existing, bootstrap-owned Search UAMI.') +@minLength(1) +param sharedSearchIdentityResourceId string + +@description('Nonzero project UAMI OBJECT/principal GUID in canonical 8-4-4-4-12 form, not its client/application ID.') +@minLength(36) +@maxLength(36) +param sharedProjectPrincipalId string + +// Bicep has no regex parameter decorator. Check every GUID position, then make +// malformed or zero GUIDs fail expression evaluation rather than reach a grant. +var invalidPrincipalPositions = filter(range(0, 36), position => contains([8, 13, 18, 23], position) + ? substring(sharedProjectPrincipalId, position, 1) != '-' + : !contains('0123456789abcdef', toLower(substring(sharedProjectPrincipalId, position, 1)))) +var principalValidationIndex = length(invalidPrincipalPositions) + (sharedProjectPrincipalId == '00000000-0000-0000-0000-000000000000' ? 1 : 0) +var validatedProjectPrincipalId = [sharedProjectPrincipalId][principalValidationIndex] + +// Keep the full run hash to avoid collisions in globally unique resource names. +var uniqueSuffix = uniqueString('${resourceGroup().id}-${deploymentTimestamp}') +var accountName = toLower('${accountPrefix}${uniqueSuffix}') +var projectName = toLower('${projectPrefix}${uniqueSuffix}') +var cosmosDBName = toLower('${uniqueSuffix}cosmosdb') +var aiSearchName = toLower('${uniqueSuffix}search') +var azureStorageName = toLower('${uniqueSuffix}storage') + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: azureStorageName + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_ZRS' + } + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + virtualNetworkRules: [] + } + allowSharedKeyAccess: false + } +} + +resource aiSearch 'Microsoft.Search/searchServices@2024-06-01-preview' = { + name: aiSearchName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sharedSearchIdentityResourceId}': {} + } + } + sku: { + name: 'standard' + } + properties: { + disableLocalAuth: false + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + encryptionWithCmk: { + enforcement: 'Unspecified' + } + hostingMode: 'default' + partitionCount: 1 + publicNetworkAccess: 'enabled' + replicaCount: 1 + semanticSearch: 'free' + } +} + +resource cosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' = { + name: cosmosDBName + location: location + kind: 'GlobalDocumentDB' + properties: { + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + disableLocalAuth: true + enableAutomaticFailover: false + enableMultipleWriteLocations: false + enableFreeTier: false + publicNetworkAccess: 'Enabled' + locations: [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + databaseAccountOfferType: 'Standard' + } +} + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: accountName + location: location + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sharedAccountIdentityResourceId}': {} + } + } + properties: { + allowProjectManagement: true + customSubDomainName: accountName + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + disableLocalAuth: false + } + dependsOn: [ + storage + aiSearch + cosmosDB + ] +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: account + name: projectName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sharedProjectIdentityResourceId}': {} + } + } + properties: { + description: 'some description' + displayName: 'project' + } +} + +resource cosmosConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: project + name: cosmosDB.name + properties: { + category: 'CosmosDB' + target: cosmosDB.properties.documentEndpoint + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: cosmosDB.id + location: cosmosDB.location + } + } +} + +resource storageConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: project + name: storage.name + properties: { + category: 'AzureStorageAccount' + target: storage.properties.primaryEndpoints.blob + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: storage.id + location: storage.location + } + } +} + +resource searchConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: project + name: aiSearch.name + properties: { + category: 'CognitiveSearch' + target: 'https://${aiSearch.name}.search.windows.net' + authType: 'AAD' + metadata: { + ApiType: 'Azure' + ResourceId: aiSearch.id + location: aiSearch.location + } + } +} + +resource accountCapabilityHost 'Microsoft.CognitiveServices/accounts/capabilityHosts@2025-04-01-preview' = { + parent: account + name: 'caphostacc' + properties: { + capabilityHostKind: 'Agents' + } + // The official capability-host module starts only after project connections exist. + dependsOn: [ + cosmosConnection + storageConnection + searchConnection + ] +} + +resource projectCapabilityHost 'Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview' = { + parent: project + name: 'caphostproj' + properties: { + // Preserve the pinned service property omitted by the bundled project schema. + #disable-next-line BCP037 + capabilityHostKind: 'Agents' + vectorStoreConnections: [ + searchConnection.name + ] + storageConnections: [ + storageConnection.name + ] + threadStorageConnections: [ + cosmosConnection.name + ] + } + dependsOn: [ + // This parent host already waits for all three project connections. + accountCapabilityHost + ] +} + +var cosmosDataContributorRoleDefinitionId = '${cosmosDB.id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002' + +// Cosmos native data-plane RBAC is not Microsoft.Authorization/roleAssignments. +// Retain this per-new-account grant after the capability host creates enterprise_memory. +// project.id supplies the stable GUID seed without the undocumented internalId property. +resource cosmosDataContributor 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-05-15' = { + parent: cosmosDB + name: guid(project.id, cosmosDB.name, cosmosDataContributorRoleDefinitionId, validatedProjectPrincipalId) + properties: { + principalId: validatedProjectPrincipalId + roleDefinitionId: cosmosDataContributorRoleDefinitionId + scope: '${cosmosDB.id}/dbs/enterprise_memory' + } + dependsOn: [ + projectCapabilityHost + ] +} + +output accountName string = account.name +output projectName string = project.name +output storageAccountName string = storage.name +output searchServiceName string = aiSearch.name +output projectResourceId string = project.id +output projectEndpoint string = 'https://${account.name}.services.ai.azure.com/api/projects/${project.name}' +output projectIdentityResourceId string = sharedProjectIdentityResourceId +output projectPrincipalId string = validatedProjectPrincipalId diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/run_samples.py b/sdk/ai/azure-ai-projects/tests/samples/ci/run_samples.py new file mode 100644 index 000000000000..9d468f06b6b9 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/run_samples.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Run resolved sample pytest nodes against the Bicep-created project, without recording.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time + +REQUIRED_BINDINGS = {"AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP"} +SECRET_NAME_SEGMENTS = {"PAT", "SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "CREDENTIALS"} + + +def allowed_environment_names(plan: dict) -> set[str]: + """Names the plan declared for the saved state; they become pytest CLI options.""" + names = plan.get("environmentBindings") + if ( + not isinstance(names, list) + or not REQUIRED_BINDINGS <= set(names) + or any(not isinstance(name, str) or not re.fullmatch(r"[A-Z][A-Z0-9_]*", name) for name in names) + ): + raise ValueError("The deployment plan must list its environment bindings.") + if any(SECRET_NAME_SEGMENTS & set(name.split("_")) for name in names): + raise ValueError("The deployment plan must not list secret-looking environment bindings.") + return set(names) + + +def require_validation_endpoint() -> str: + """Read the existing validation project only from the task environment.""" + endpoint = os.environ.get("LLM_VALIDATION_PROJECT_ENDPOINT", "") + if not re.fullmatch(r"https://[A-Za-z0-9-]+\.services\.ai\.azure\.com/api/projects/[A-Za-z0-9._-]+/?", endpoint): + raise ValueError( + "Set LLM_VALIDATION_PROJECT_ENDPOINT as a pipeline environment variable containing an existing Foundry project endpoint. " + "The value was suppressed; no validator will be provisioned." + ) + return endpoint + + +def build_command(state: dict, junit_path: Path) -> list[str]: + if state.get("schemaVersion") != 1 or state.get("status") != "Ready": + raise ValueError("Bicep provisioning must complete before tests can run.") + if state.get("plan", {}).get("queueName") not in {"q1", "q2", "q3", "q4"}: + raise ValueError("Deployment state must identify one of the four test queues.") + environment = state.get("environment", {}) + allowed = allowed_environment_names(state.get("plan", {})) + if set(environment) - allowed or any(not environment.get(name) for name in allowed): + raise ValueError("Deployment state must contain only the expected non-secret environment bindings.") + for environment_name, state_key, label in ( + ("AZURE_SUBSCRIPTION_ID", "subscriptionId", "subscription"), + ("AZURE_TENANT_ID", "tenantId", "tenant"), + ): + value = state.get(state_key, "") + planned_value = state.get("plan", {}).get(state_key, "") + environment_value = environment[environment_name] + if ( + not isinstance(value, str) + or not re.fullmatch(r"[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}", value) + or not isinstance(planned_value, str) + or planned_value.lower() != value.lower() + or not isinstance(environment_value, str) + or environment_value.lower() != value.lower() + ): + raise ValueError(f"Deployment state, plan and {environment_name} must identify the same explicit {label}.") + tests = state.get("plan", {}).get("selectedTests", []) + nodes = [test["node_id"] for test in tests] + if not nodes or any( + not re.fullmatch(r"tests/samples/test_samples(?:_async)?\.py::TestSamples(?:Async)?::\w+\[[^\r\n]+\]", node) + for node in nodes + ): + raise ValueError("Only collected sync/async sample test nodes may be executed.") + arguments = [ + sys.executable, + "-m", + "pytest", + *nodes, + "-p", + "sample_ci_attachments", + f"--sample-attachments-dir={junit_path.resolve().parent / 'attachments'}", + "-o", + "junit_logging=system-out", + "-o", + "junit_log_passing_tests=true", + "-o", + "log_cli=false", + "-o", + "addopts=", + "-v", + "--capture=fd", + "--tb=short", + f"--junitxml={junit_path}", + "--azure-test-run-live=true", + "--azure-skip-live-recording=true", + ] + # Supplying CLI environment options prevents conftest from replacing live values with .env/defaults. + for name, value in sorted(environment.items()): + if not isinstance(value, str): + raise ValueError("Deployment environment bindings must be strings.") + # The harness has no tenant CLI option; preserve this authentication setting in the environment only. + if value and name != "AZURE_TENANT_ID": + arguments.append(f"--{name.lower().replace('_', '-')}={value}") + return arguments + + +def wait_for_project(endpoint: str, timeout: float = 600) -> None: + from azure.core.exceptions import HttpResponseError # pylint: disable=import-outside-toplevel + from azure.identity import AzureCliCredential # pylint: disable=import-outside-toplevel + from azure.ai.projects import AIProjectClient # pylint: disable=import-outside-toplevel + + deadline = time.monotonic() + timeout + with AzureCliCredential() as credential, AIProjectClient(endpoint, credential) as client: + while True: + try: + next(iter(client.agents.list(limit=1, connection_timeout=10, read_timeout=30, retry_total=0)), None) + print("Project/RBAC readiness succeeded; selected sample tests may now start.") + return + except HttpResponseError as error: + remaining = deadline - time.monotonic() + # A freshly provisioned Standard project can return this specific index timeout as + # invalid_configuration rather than a 5xx. Do not retry other configuration errors. + agent_index_timeout = getattr(error.error, "code", None) == "invalid_configuration" and re.match( + r"^Listing agents timed out after \d+(?:\.\d+)?s while reading the agent index\.", + getattr(error.error, "message", "") or "", + ) + if error.status_code not in {403, 404, 409, 429, 500, 502, 503, 504} and not agent_index_timeout: + raise + reason = "agent-index-timeout" if agent_index_timeout else "propagation-or-service-error" + if remaining <= 0: + raise TimeoutError( + f"Project readiness did not succeed within {timeout:g}s " + f"(HTTP {error.status_code}, {reason}); sample not started." + ) from None + print( + f"Waiting for project/RBAC readiness (HTTP {error.status_code}, {reason}); " + "sample not yet started." + ) + time.sleep(min(10, remaining)) + if time.monotonic() >= deadline: + raise TimeoutError( + f"Project readiness did not succeed within {timeout:g}s " + f"(HTTP {error.status_code}, {reason}); sample not started." + ) from None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path) + parser.add_argument("--junit", type=Path) + parser.add_argument("--check-validation-config", action="store_true") + parser.add_argument("--package-root", type=Path, default=Path(__file__).resolve().parents[3]) + arguments = parser.parse_args() + require_validation_endpoint() + if arguments.check_validation_config: + print( + "External validation project environment variable is configured; no Azure resources were queried or changed." + ) + return 0 + if arguments.state is None or arguments.junit is None: + parser.error("--state and --junit are required when running samples") + state = json.loads(arguments.state.read_text(encoding="utf-8-sig")) + command = build_command(state, arguments.junit) + if os.environ.get("SAMPLE_QUEUE_NAME", "") != state["plan"]["queueName"]: + raise ValueError("The task SAMPLE_QUEUE_NAME does not match the deployment state; sample not started.") + for environment_name, state_key in (("AZURE_SUBSCRIPTION_ID", "subscriptionId"), ("AZURE_TENANT_ID", "tenantId")): + if os.environ.get(environment_name, "").lower() != state[state_key].lower(): + raise ValueError(f"The task {environment_name} does not match the deployment state; sample not started.") + environment = os.environ.copy() + environment.update(state["environment"]) + environment.update( + PYTHON_DOTENV_DISABLED="true", + AZURE_TEST_RUN_LIVE="true", + AZURE_SKIP_LIVE_RECORDING="true", + AZURE_TEST_USE_CLI_AUTH="true", + AZURE_TOKEN_CREDENTIALS="AzureCliCredential", + AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES=json.dumps(sorted(allowed_environment_names(state["plan"]))), + PYTEST_ADDOPTS="", + PYTHONIOENCODING="utf-8", + ) + environment.pop("MCP_GITHUB_PAT", None) + environment.pop("PROJ_CONN_OPEN_API_KEY", None) + environment.pop("SYSTEM_ACCESSTOKEN", None) + environment["PYTHONPATH"] = os.pathsep.join( + (str(arguments.package_root), str(Path(__file__).resolve().parent), environment.get("PYTHONPATH", "")) + ) + # Readiness is read-only; retry propagation errors, never retry a partially executed sample. + os.environ["PYTHON_DOTENV_DISABLED"] = "true" + wait_for_project(state["environment"]["FOUNDRY_PROJECT_ENDPOINT"]) + arguments.junit.parent.mkdir(parents=True, exist_ok=True) + return subprocess.run(command, cwd=arguments.package_root, env=environment, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/sample_ci_attachments.py b/sdk/ai/azure-ai-projects/tests/samples/ci/sample_ci_attachments.py new file mode 100644 index 000000000000..1b1edab11287 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/sample_ci_attachments.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Opt-in pytest plugin for sanitized sample log attachments in Azure DevOps JUnit results.""" + +from __future__ import annotations + +from functools import wraps +import hashlib +import os +from pathlib import Path + +import pytest + +_ATTACHMENTS = pytest.StashKey[list[Path]]() + + +def sample_log_directory(node_id: str, directory: Path, executor_index: int = 0) -> Path: + """Keep the executor's own log files isolated per pytest node and executor.""" + target = directory.resolve() / hashlib.sha256(node_id.encode("utf-8")).hexdigest()[:20] / str(executor_index) + if any(character in str(target) for character in "\r\n|[]"): + raise ValueError("The attachment directory cannot contain JUnit attachment-marker delimiters.") + return target + + +def sample_attachment_paths(executors: list, directory: Path) -> list[Path]: + """Attach the shared executor's already-sanitized logs without copying or reformatting them.""" + paths = [Path(path) for executor in executors for path in executor.write_capture_logs()] + root = directory.resolve() + if any(not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(root) for path in paths): + raise ValueError("Sample log files must remain within this run's attachment directory.") + return paths + + +def pytest_addoption(parser): + parser.getgroup("sample CI attachments").addoption( + "--sample-attachments-dir", help="Publish sanitized live sample logs under this run's result directory." + ) + + +@pytest.fixture(autouse=True) +def _capture_sample_attachments(request, monkeypatch): + directory = request.config.getoption("sample_attachments_dir") + callspec = getattr(request.node, "callspec", None) + if ( + not directory + or os.environ.get("AZURE_TEST_RUN_LIVE") != "true" + or not callspec + or "sample_path" not in callspec.params + ): + yield + return + + # Both sync and async executors inherit this constructor and its in-memory capture lists. + # Patch only for this explicitly opted-in test; ordinary playback/live harness behavior is unchanged. + from sample_executor import BaseSampleExecutor # pylint: disable=import-outside-toplevel + + executors = [] + original_init = BaseSampleExecutor.__init__ + + @wraps(original_init) + def tracked_init(executor, *args, **kwargs): + kwargs["log_directory"] = str(sample_log_directory(request.node.nodeid, Path(directory), len(executors))) + original_init(executor, *args, **kwargs) + executors.append(executor) + + monkeypatch.setattr(BaseSampleExecutor, "__init__", tracked_init) + yield + try: + request.node.stash[_ATTACHMENTS] = sample_attachment_paths(executors, Path(directory)) + except Exception: # pylint: disable=broad-exception-caught + pytest.fail("Sample attachments could not be created; raw log content was suppressed.", pytrace=False) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + report = outcome.get_result() + if item.config.getoption("sample_attachments_dir"): + from sample_log import sanitize_log # pylint: disable=import-outside-toplevel + + # Test output is untrusted: only the paths appended below may become JUnit attachments. + report.sections = [ + (name, sanitize_log(content).replace("[[ATTACHMENT|", "[[REDACTED-ATTACHMENT|")) + for name, content in report.sections + ] + if report.failed and report.longrepr is not None: + report.longrepr = sanitize_log(str(report.longrepr)) + if report.when == "teardown": + paths = item.stash.get(_ATTACHMENTS, []) + if paths: + markers = "\n".join(f"[[ATTACHMENT|{path}]]" for path in paths) + report.sections.append(("Captured stdout teardown", markers)) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/search_documents/product_info.md b/sdk/ai/azure-ai-projects/tests/samples/ci/search_documents/product_info.md new file mode 100644 index 000000000000..48dfc503dd5a --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/search_documents/product_info.md @@ -0,0 +1,51 @@ +# Information about product item_number: 1 + +## Brand +Contoso Galaxy Innovations + +## Category +Smart Eyewear + +## Features +- Augmented Reality interface +- Voice-controlled AI agent +- HD video recording with 3D audio +- UV protection and blue light filtering +- Wireless charging with extended battery life + +## User Guide + +### 1. Introduction +Introduction to your new SmartView Glasses + +### 2. Product Overview +Overview of features and controls + +### 3. Sizing and Fit +Finding your perfect fit and style adjustments + +### 4. Proper Care and Maintenance +Cleaning and caring for your SmartView Glasses + +### 5. Break-in Period +Adjusting to the augmented reality experience + +### 6. Safety Tips +Safety guidelines for public and private spaces + +### 7. Troubleshooting +Quick fixes for common issues + +## Warranty Information +Two-year limited warranty on all electronic components + +## Contact Information +Customer Support at support@contoso-galaxy-innovations.com + +## Return Policy +30-day return policy with no questions asked + +## FAQ +- How to sync your SmartView Glasses with your devices +- Troubleshooting connection issues +- Customizing your augmented reality environment diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/select_samples.py b/sdk/ai/azure-ai-projects/tests/samples/ci/select_samples.py new file mode 100644 index 000000000000..192840820866 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/select_samples.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Resolve wildcard queries against the actual sync/async pytest sample collection.""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import re +import sys + + +@dataclass(frozen=True) +class SampleTest: + node_id: str + sample_path: str + test_id: str + + +def normalize(value: str) -> str: + """Use package-relative, forward-slash paths on Windows and Linux.""" + value = value.replace("\\", "/") + while value.startswith("./"): + value = value[2:] + prefix = "sdk/ai/azure-ai-projects/" + if value.lower().startswith(prefix): + value = value[len(prefix) :] + return value + + +def matches(value: str, pattern: str) -> bool: + """Only * and ? are special; pytest parameter brackets remain literal.""" + expression = re.escape(normalize(pattern)).replace(r"\*", ".*").replace(r"\?", ".") + return re.fullmatch(expression, normalize(value), flags=re.IGNORECASE) is not None + + +def select_tests(tests: list[SampleTest], selection_kind: str, query: str) -> list[SampleTest]: + if selection_kind not in {"sample-path", "test-id"}: + raise ValueError("Selection kind must be sample-path or test-id.") + patterns = [pattern.strip() for pattern in re.split(r"[;\r\n]+", query) if pattern.strip()] + if not patterns: + raise ValueError("Supply a sample path or test ID wildcard; an empty query never selects all tests.") + + selected = {} + for test in tests: + candidates = [test.sample_path] if selection_kind == "sample-path" else [test.node_id, test.test_id] + if any(matches(candidate, pattern) for candidate in candidates for pattern in patterns): + selected[test.node_id] = test + if not selected: + raise ValueError( + f"No collected sample tests match {selection_kind} query {query!r}. " + "Use * or ? wildcards, not pytest -k expressions. Samples excluded by the harness are not selected." + ) + return sorted(selected.values(), key=lambda test: test.node_id) + + +def partition_tests(tests: list[SampleTest], queue_count: int) -> dict[str, list[SampleTest]]: + """Assign each selected case once, preserving manifest order within each queue.""" + if type(queue_count) is not int or not 1 <= queue_count <= 4: + raise ValueError("Request one to four test queues.") + if not tests or len({test.node_id for test in tests}) != len(tests): + raise ValueError("Queue assignment requires a nonempty selection of unique test nodes.") + active = min(queue_count, len(tests)) + return {f"q{index + 1}": tests[index::active] for index in range(active)} + + +class SampleCollector: + def __init__(self, package_root: Path): + self.package_root = package_root.resolve() + self.tests: list[SampleTest] = [] + + def pytest_collection_finish(self, session): + for item in session.items: + callspec = getattr(item, "callspec", None) + sample_path = callspec.params.get("sample_path") if callspec is not None else None + if not isinstance(sample_path, (str, os.PathLike)): + continue + path = Path(sample_path).resolve() + try: + relative_path = path.relative_to(self.package_root / "samples") + except ValueError: + continue + self.tests.append( + SampleTest( + node_id=normalize(item.nodeid), + sample_path=f"samples/{relative_path.as_posix()}", + test_id=str(callspec.id), + ) + ) + + +def collect_tests(package_root: Path) -> list[SampleTest]: + # Collection must never inherit a developer's .env or execute a sample. + os.environ["PYTHON_DOTENV_DISABLED"] = "true" + import pytest # pylint: disable=import-outside-toplevel + + collector = SampleCollector(package_root) + arguments = [ + "--collect-only", + "-q", + f"--rootdir={package_root}", + "--azure-test-run-live=false", + "--azure-skip-live-recording=true", + str(package_root / "tests/samples/test_samples.py"), + str(package_root / "tests/samples/test_samples_async.py"), + ] + original_directory = Path.cwd() + try: + os.chdir(package_root) + sys.path.insert(0, str(package_root)) + result = pytest.main(arguments, plugins=[collector]) + finally: + os.chdir(original_directory) + if result != pytest.ExitCode.OK: + raise ValueError( + f"Sample test collection failed (pytest exit code {int(result)}); no resources were provisioned." + ) + return collector.tests + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--selection-kind", choices=["sample-path", "test-id"], required=True) + parser.add_argument("--selection", required=True, help="* and ? wildcards; separate OR patterns with semicolons.") + parser.add_argument("--package-root", type=Path, default=Path(__file__).resolve().parents[3]) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--queue-count", type=int, choices=range(1, 5), default=1) + arguments = parser.parse_args() + if arguments.output.exists() or any((arguments.output.parent / f"q{number}").exists() for number in range(1, 5)): + parser.error("Use a fresh output directory; previous queue plans must not be reused or overwritten.") + try: + selected = select_tests(collect_tests(arguments.package_root), arguments.selection_kind, arguments.selection) + queues = partition_tests(selected, arguments.queue_count) + except ValueError as error: + parser.error(str(error)) + + manifest = { + "schemaVersion": 1, + "selectionKind": arguments.selection_kind, + "selection": arguments.selection, + "tests": [asdict(test) for test in selected], + } + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + matrix = {} + for queue, tests in queues.items(): + directory = arguments.output.parent / queue + directory.mkdir() + shard = {**manifest, "tests": [asdict(test) for test in tests]} + (directory / "selected-tests.json").write_text(json.dumps(shard, indent=2) + "\n", encoding="utf-8") + matrix[queue] = {"QueueName": queue} + print(f"{queue}: {len(tests)} assigned sample test(s).") + (arguments.output.parent / "queue-matrix.json").write_text(json.dumps(matrix) + "\n", encoding="utf-8") + print(f"\nSelected {len(selected)} sample test(s):") + for test in selected: + print(f" {test.sample_path}\n {test.node_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_pipeline.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_pipeline.py new file mode 100644 index 000000000000..84de13d41767 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_pipeline.py @@ -0,0 +1,400 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline checks for the on-demand pipeline's automatic live-run flow.""" + +import json +from pathlib import Path + +import pytest +import yaml + + +@pytest.fixture +def pipeline(): + path = Path(__file__).resolve().parents[3] / "tests.yml" + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def live_stages(pipeline): + stages = pipeline["extends"]["parameters"]["stages"] + return [stage for stage in stages if stage.get("stage") == "RunSamples"] + + +def worker(pipeline): + return next(job for job in live_stages(pipeline)[0]["jobs"] if job.get("job") == "ProvisionTestCleanup") + + +def test_no_manual_approval_stage_or_task(pipeline): + stages = live_stages(pipeline) + assert [stage["stage"] for stage in stages] == ["RunSamples"] + for stage in stages: + for job in stage["jobs"]: + assert job.get("pool") != "server" + assert not any(step.get("task", "").startswith("ManualValidation@") for step in job.get("steps", [])) + + +def test_manual_run_requires_successful_internal_planning_without_a_preview_choice(pipeline): + assert pipeline["trigger"] == "none" + assert pipeline["pr"] == "none" + assert "runMode" not in {parameter["name"] for parameter in pipeline["parameters"]} + stages = pipeline["extends"]["parameters"]["stages"] + assert [stage["stage"] for stage in stages] == ["Plan", "RunSamples"] + run_stage = next(stage for stage in live_stages(pipeline) if stage["stage"] == "RunSamples") + assert run_stage["dependsOn"] == "Plan" + assert run_stage["condition"] == "succeeded()" + + +def test_run_form_uses_project_mi_without_connection_group_or_region_fields(pipeline): + parameters = {parameter["name"]: parameter for parameter in pipeline["parameters"]} + assert {"runMode", "azureServiceConnection", "secretVariableGroup", "location"}.isdisjoint(parameters) + assert "projectIdentityName" not in parameters + assert "identityNamePattern" not in parameters + # No default: ADO prompts for the resource group holding the shared identities. + assert "default" not in parameters["identityResourceGroup"] + assert "resource group" in parameters["identityResourceGroup"]["displayName"].lower() + assert pipeline["variables"]["azureServiceConnection"] == "azure-sdk-tests" + stages = pipeline["extends"]["parameters"]["stages"] + assert all("variables" not in stage for stage in stages) + azure_tasks = [ + step for job in live_stages(pipeline)[0]["jobs"] for step in job["steps"] if step.get("task") == "AzureCLI@2" + ] + assert len(azure_tasks) == 5 + assert all(step["inputs"]["azureSubscription"] == "${{ variables.azureServiceConnection }}" for step in azure_tasks) + + +def test_cleanup_and_result_publication_still_run_on_failure(pipeline): + run_stage = next(stage for stage in live_stages(pipeline) if stage["stage"] == "RunSamples") + job = worker(pipeline) + assert job["cancelTimeoutInMinutes"] == 30 + cleanup = next(step for step in job["steps"] if "Remove-SampleResources.ps1" in str(step)) + assert cleanup["condition"] == "always()" + assert "-ConfirmCleanup" in cleanup["inputs"]["inlineScript"] + results = next(step for step in job["steps"] if step.get("task") == "PublishTestResults@2") + assert results["condition"] == "always()" + assert results["inputs"]["publishRunAttachments"] is True + assert results["inputs"]["failTaskOnFailureToPublishResults"] is True + + +@pytest.mark.parametrize("secret", ["MCP_GITHUB_PAT", "PROJ_CONN_OPEN_API_KEY"]) +def test_secrets_stay_scoped_to_provisioning_task(pipeline, secret): + run_stage = next(stage for stage in live_stages(pipeline) if stage["stage"] == "RunSamples") + steps = worker(pipeline)["steps"] + secret_steps = [step for step in steps if secret in step.get("env", {})] + assert len(secret_steps) == 1 + provisioning = secret_steps[0] + assert provisioning["env"][secret] == f"$({secret})" + assert "Invoke-SampleProvisioning.ps1" in provisioning["inputs"]["inlineScript"] + assert "-ConfirmProvisioning" in provisioning["inputs"]["inlineScript"] + assert secret not in {parameter["name"] for parameter in pipeline["parameters"]} + + +def test_failed_runner_still_fails_before_always_run_cleanup(pipeline): + run_stage = next(stage for stage in live_stages(pipeline) if stage["stage"] == "RunSamples") + steps = worker(pipeline)["steps"] + runner = next(step for step in steps if step.get("task") == "AzureCLI@2" and "--junit" in str(step)) + script = runner["inputs"]["inlineScript"] + assert "$sampleExitCode = $LASTEXITCODE" in script + assert "if ($sampleExitCode -ne 0)" in script + assert "throw 'Selected sample runner failed" in script + assert steps.index(runner) < next(i for i, step in enumerate(steps) if "Remove-SampleResources.ps1" in str(step)) + + +def test_external_validator_is_env_only_and_checked_before_provisioning(pipeline): + parameters = {parameter["name"] for parameter in pipeline["parameters"]} + assert {"LLM_VALIDATION_PROJECT_ENDPOINT", "validationModelVersion", "validationModelCapacity"}.isdisjoint( + parameters + ) + steps = worker(pipeline)["steps"] + validation_steps = [step for step in steps if "LLM_VALIDATION_PROJECT_ENDPOINT" in step.get("env", {})] + assert len(validation_steps) == 2 + preflight = next(step for step in validation_steps if "--check-validation-config" in str(step)) + runner = next(step for step in validation_steps if "--junit" in str(step)) + provisioning = next(step for step in steps if "Invoke-SampleProvisioning.ps1" in str(step)) + for step in validation_steps: + assert step["env"]["LLM_VALIDATION_PROJECT_ENDPOINT"] == "$(LLM_VALIDATION_PROJECT_ENDPOINT)" + assert "LLM_VALIDATION_PROJECT_ENDPOINT" not in provisioning.get("env", {}) + assert steps.index(preflight) < steps.index(provisioning) < steps.index(runner) + assert preflight["task"] == "AzureCLI@2" + assert preflight["inputs"]["azureSubscription"] == provisioning["inputs"]["azureSubscription"] + assert "Set-SampleSubscriptionContext" in preflight["inputs"]["inlineScript"] + assert "pipeline-identity.json" in preflight["inputs"]["inlineScript"] + assert "MCP_GITHUB_PAT" not in preflight.get("env", {}) + + +def test_rejected_samples_skip_only_themselves_then_fail_the_run(pipeline): + stages = pipeline["extends"]["parameters"]["stages"] + planning = next(stage for stage in stages if stage.get("stage") == "Plan") + plan = next(step for step in planning["jobs"][0]["steps"] if "New-SampleDeploymentPlan.ps1" in str(step)) + assert "deployment-plan.json" in plan["pwsh"] + assert "RejectedTestCount;isOutput=true" in plan["pwsh"] + assert "task.complete result=SucceededWithIssues" in plan["pwsh"] + report = next(job for job in live_stages(pipeline)[0]["jobs"] if job.get("job") == "ReportRejectedSamples") + assert report["dependsOn"] == "ProvisionTestCleanup" + assert report["condition"] == "succeededOrFailed()" + assert "Plan.RejectedTestCount" in report["variables"]["rejectedCount"] + assert report["steps"][0] == {"checkout": "none"} + assert "REJECTED_COUNT" in report["steps"][1]["pwsh"] + + +def test_validator_model_is_absent_from_plan_and_provisioning_sources(): + directory = Path(__file__).resolve().parent + for name in ("New-SampleDeploymentPlan.ps1", "Invoke-SampleProvisioning.ps1", "infra/sample-resources.bicepparam"): + source = (directory / name).read_text(encoding="utf-8").lower() + assert "validationmodel" not in source + assert "validation_model" not in source + template = (directory / "infra/sample-resources.bicep").read_text(encoding="utf-8") + assert "validationDeployment" not in template + assert "validationModel" not in template + assert "LLM_VALIDATION_PROJECT_ENDPOINT" not in template + + +@pytest.mark.parametrize("variable", ["AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID"]) +def test_target_ids_are_explicit_in_plan_and_every_live_azure_task(pipeline, variable): + stages = pipeline["extends"]["parameters"]["stages"] + planning = next(stage for stage in stages if stage.get("stage") == "Plan") + plan = next(step for step in planning["jobs"][0]["steps"] if "New-SampleDeploymentPlan.ps1" in str(step)) + assert plan["env"][variable] == f"$({variable})" + azure_tasks = [step for step in worker(pipeline)["steps"] if step.get("task") == "AzureCLI@2"] + assert len(azure_tasks) == 4 + for step in azure_tasks: + assert step["env"][variable] == f"$({variable})" + assert variable not in {parameter["name"] for parameter in pipeline["parameters"]} + assert variable not in pipeline["variables"] + preflight = next(step for step in azure_tasks if "--check-validation-config" in str(step)) + runner = next(step for step in azure_tasks if "--junit" in str(step)) + assert "Set-SampleSubscriptionContext -Plan" in preflight["inputs"]["inlineScript"] + assert "Set-SampleSubscriptionContext -Plan" in runner["inputs"]["inlineScript"] + + +def test_default_model_and_region_use_verified_gpt5_configuration(pipeline): + parameters = {parameter["name"]: parameter for parameter in pipeline["parameters"]} + assert parameters["modelName"]["default"] == "gpt-5.2" + assert parameters["modelVersion"]["default"] == "2025-12-11" + assert parameters["modelSku"]["default"] == "GlobalStandard" + assert parameters["modelCapacity"]["default"] == 10 + + +def test_internal_plan_only_collects_and_plans_without_test_framework_bootstrap(pipeline): + stages = pipeline["extends"]["parameters"]["stages"] + planning = next(stage for stage in stages if stage.get("stage") == "Plan") + steps = planning["jobs"][0]["steps"] + scripts = [step["pwsh"] for step in steps if "pwsh" in step] + assert len(scripts) == 2 # Dependency installation, then collection and plan generation. + assert "select_samples.py" in scripts[1] + assert "New-SampleDeploymentPlan.ps1" in scripts[1] + assert "-IdentityResourceGroup $env:SAMPLE_IDENTITY_RESOURCE_GROUP" in scripts[1] + assert "-Location" not in scripts[1] + assert steps[-1]["env"]["SAMPLE_IDENTITY_RESOURCE_GROUP"] == "${{ parameters.identityResourceGroup }}" + assert "SAMPLE_MI_NAME_PATTERN" not in steps[-1]["env"] + assert "SAMPLE_PROJECT_IDENTITY_NAME" not in steps[-1]["env"] + assert "SAMPLE_LOCATION" not in steps[-1]["env"] + for script in scripts: + assert not any( + command in script + for command in ( + "Invoke-Pester", + "Install-Module", + "Test-BicepTemplates", + "ConfirmProvisioning", + "ConfirmCleanup", + ) + ) + assert not any(step.get("task") == "AzureCLI@2" for step in steps) + + +def test_provisioning_uses_shared_variant_without_worker_iam_writes(): + directory = Path(__file__).resolve().parent + source = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + assert source.index("Get-SampleSharedIdentities -Plan $plan") < source.index("$state = @{") + assert source.index("Set-Content -LiteralPath $StatePath") < source.index("@('deployment', 'sub', 'create')") + assert "[Parameter(Mandatory)] [string] $StandardTemplatePath" not in source + helper = (directory / "SampleCiHelpers.ps1").read_text(encoding="utf-8") + assert "function Get-SampleStandardTemplate" in helper + assert "SHA256]::HashData($bytes)" in helper + assert "checksum mismatch" in helper + assert "infra/main.bicep" in source + root = (directory / "infra/main.bicep").read_text(encoding="utf-8") + assert "'./standard-shared-identities.bicep'" in root + for name in ("sample-resources.bicep", "standard-shared-identities.bicep"): + assert "'Microsoft.Authorization/roleAssignments@" not in (directory / "infra" / name).read_text( + encoding="utf-8" + ) + + +def test_subscription_is_not_derived_from_the_service_connection_default(): + directory = Path(__file__).resolve().parent + source = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + assert "$subscription = [string] $account.id" not in source + assert "Set-SampleSubscriptionContext -Plan $plan" in source + assert "$subscription = Get-SampleSubscriptionId" in source + plan = (directory / "New-ResourceDeploymentPlan.ps1").read_text(encoding="utf-8") + assert "subscriptionId = $subscription" in plan + cleanup = (directory / "Remove-SampleResources.ps1").read_text(encoding="utf-8") + assert "$state['subscriptionId'] -ine $state.plan.subscriptionId" in cleanup + assert "Set-SampleSubscriptionContext -Plan $state.plan" in cleanup + + +def test_tenant_is_required_from_environment_and_bound_to_plan_and_cleanup(): + directory = Path(__file__).resolve().parent + plan = (directory / "New-ResourceDeploymentPlan.ps1").read_text(encoding="utf-8") + assert "$tenant = Get-SampleTenantId" in plan + assert "tenantId = $tenant" in plan + provisioning = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + assert "tenantId = $runnerIdentity.tenantId" in provisioning + assert "$state.environment['AZURE_TENANT_ID'] = $runnerIdentity.tenantId" in provisioning + cleanup = (directory / "Remove-SampleResources.ps1").read_text(encoding="utf-8") + assert "$state['tenantId'] -ine $state.plan.tenantId" in cleanup + + +def test_round_robin_workers_are_isolated_without_an_environment_dependency(pipeline): + parameters = {parameter["name"]: parameter for parameter in pipeline["parameters"]} + assert parameters["testQueueCount"]["default"] == 4 + assert parameters["testQueueCount"]["values"] == [1, 2, 3, 4] + selection = parameters["selection"]["default"].split(";") + assert selection == [ + "samples/agents/tools/sample_agent_mcp_with_project_connection.py", + "samples/agents/tools/sample_agent_mcp_with_project_connection_async.py", + ] + stage = live_stages(pipeline)[0] + assert "lockBehavior" not in stage + assert all("environment" not in job and "deployment" not in job for job in stage["jobs"]) + admission = next(job for job in stage["jobs"] if job.get("job") == "AdmitTestQueues") + steps = admission["steps"] + preflight = next(step for step in steps if step.get("task") == "AzureCLI@2") + assert "-BatchPlanDirectory" in preflight["inputs"]["inlineScript"] + assert "-ResolvedPlanDirectory" in preflight["inputs"]["inlineScript"] + assert admission["templateContext"]["outputs"][0]["artifactName"] == "resolved-sample-plan" + assert preflight["env"] == { + "AZURE_SUBSCRIPTION_ID": "$(AZURE_SUBSCRIPTION_ID)", + "AZURE_TENANT_ID": "$(AZURE_TENANT_ID)", + } + job = worker(pipeline) + download = next(step for step in job["steps"] if step.get("task") == "DownloadPipelineArtifact@2") + assert download["inputs"]["artifactName"] == "resolved-sample-plan" + assert job["dependsOn"] == "AdmitTestQueues" + assert job["strategy"]["maxParallel"] == 4 + assert job["strategy"]["matrix"] == "$[ stageDependencies.Plan.Plan.outputs['Plan.WorkerMatrix'] ]" + planning = next(stage for stage in pipeline["extends"]["parameters"]["stages"] if stage.get("stage") == "Plan") + plan = next(step for step in planning["jobs"][0]["steps"] if step.get("name") == "Plan") + assert "--queue-count $env:SAMPLE_QUEUE_COUNT" in plan["pwsh"] + assert '"$queueName/selected-tests.json"' in plan["pwsh"] + assert "queue-matrix.json" in plan["pwsh"] + assert "variable=WorkerMatrix;isOutput=true" in plan["pwsh"] + assert job["templateContext"]["outputs"][0]["artifactName"] == "sample-results-$(QueueName)" + for step in job["steps"]: + if step.get("task") == "AzureCLI@2": + assert step["env"]["SAMPLE_QUEUE_NAME"] == "$(QueueName)" + assert "SYSTEM_ACCESSTOKEN" not in step["env"] + if step.get("task") == "PublishTestResults@2": + assert step["inputs"]["searchFolder"].endswith("/$(QueueName)") + + +def test_batch_admission_retains_guards_without_querying_ado(): + directory = Path(__file__).resolve().parent + source = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + admission = source.split("if (-not $ConfirmProvisioning)", 1)[0] + for guard in ( + "Set-SampleSubscriptionContext", + "Get-SampleSharedIdentities", + "Assert-SampleBatchCapacity", + ): + assert guard in admission + assert "Assert-SampleCapacityLock" not in source + helper = (directory / "SampleCiHelpers.ps1").read_text(encoding="utf-8") + assert "function Assert-SampleCapacityLock" not in helper + assert "pipelines/checks/configurations" not in helper + + +def test_provisioning_metadata_is_passed_directly_not_through_environment_variables(): + directory = Path(__file__).resolve().parent + parameters = (directory / "infra/sample-resources.bicepparam").read_text(encoding="utf-8") + assert parameters.startswith("using none") + assert parameters.count("readEnvironmentVariable(") == 2 + assert "param mcpPat = readEnvironmentVariable('MCP_GITHUB_PAT', '')" in parameters + assert "param openApiKey = readEnvironmentVariable('PROJ_CONN_OPEN_API_KEY', '')" in parameters + provisioning = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + assert "SAMPLE_CI_" not in provisioning + parameters + assert "SetEnvironmentVariable" not in provisioning + assert '"agentName=$($plan.agentName)"' in provisioning + assert "'sampleModel=' + ($modelParameters | ConvertTo-Json -Compress)" in provisioning + assert "Invoke-SampleAzure -Arguments $bundleArguments -Sensitive" in provisioning + root = (directory / "infra/main.bicep").read_text(encoding="utf-8") + assert "accountName: standard.outputs.accountName" in root + assert "projectName: standard.outputs.projectName" in root + + +def test_function_infrastructure_is_always_provisioned_using_resolved_inputs(): + directory = Path(__file__).resolve().parent + provisioning = (directory / "Invoke-ResourceProvisioning.ps1").read_text(encoding="utf-8") + template = (directory / "infra/sample-resources.bicep").read_text(encoding="utf-8") + parameters = (directory / "infra/sample-resources.bicepparam").read_text(encoding="utf-8") + assert "enableFunctionApp" not in provisioning + template + parameters + root = (directory / "infra/main.bicep").read_text(encoding="utf-8") + assert "'./function-app.bicep'" in root + assert "storageAccountName: standard.outputs.storageAccountName" in root + assert '"sharedFunctionIdentityResourceId=$($identities.function.resourceId)"' in provisioning + assert "Test-SampleFunctionRequired" not in provisioning + assert "$requiresFunction" not in provisioning + assert "requiredEnvironment" not in provisioning + helper = (directory / "SampleCiHelpers.ps1").read_text(encoding="utf-8") + assert "Test-SampleFunctionRequired" not in helper + assert "RequireFunctionAccess" not in helper + assert "requiredEnvironment" not in helper + assert provisioning.index("Publish-SampleFunctionApp -Plan $plan") < provisioning.index( + "$state['status'] = 'Ready'" + ) + + +def test_shared_provisioning_has_no_python_planning_dependency(): + directory = Path(__file__).resolve().parent + for name in ( + "New-ResourceDeploymentPlan.ps1", + "Invoke-ResourceProvisioning.ps1", + "SampleCiHelpers.ps1", + "Remove-SampleResources.ps1", + ): + source = (directory / name).read_text(encoding="utf-8") + for dependency in ( + "selectedTests", + "requiredEnvironment", + "node_id", + "PackageRoot", + "PythonSampleCiHelpers.ps1", + ): + assert dependency not in source + adapter = (directory / "Invoke-SampleProvisioning.ps1").read_text(encoding="utf-8") + assert "Assert-PythonSamplePartition -Plans $plans" in adapter + assert "'Invoke-ResourceProvisioning.ps1') @PSBoundParameters" in adapter + plan = (directory / "New-SampleDeploymentPlan.ps1").read_text(encoding="utf-8") + assert "'New-ResourceDeploymentPlan.ps1')" in plan + assert "Get-SampleEnvironmentNames" in plan + assert "function-app.bicep" not in plan + + +@pytest.mark.parametrize( + "target, role_id", + [ + ("function", "b7e6dc6d-f1e8-4753-8033-0f276bb0955b"), + ("function", "974c5e8b-45b9-4653-ba55-5f855dd0fb88"), + ("project", "974c5e8b-45b9-4653-ba55-5f855dd0fb88"), + ], +) +def test_shared_identity_bootstrap_preconfigures_function_storage_permissions(target, role_id): + path = Path(__file__).resolve().parent.parent / "shared-identities/infra/IdentityConfiguration.json" + configuration = json.loads(path.read_text(encoding="utf-8")) + matches = [ + grant + for grant in configuration["roleAssignments"] + if grant["target"] == target and grant["roleDefinitionId"] == role_id + ] + assert len(matches) == 1 + assert "condition" not in matches[0] + assert "conditionVersion" not in matches[0] + conditioned_owner = [ + grant + for grant in configuration["roleAssignments"] + if grant["target"] == "project" and grant["roleDefinitionId"] == "b7e6dc6d-f1e8-4753-8033-0f276bb0955b" + ] + assert len(conditioned_owner) == 1 + assert "*-azureml-agent" in conditioned_owner[0]["condition"] + assert conditioned_owner[0]["conditionVersion"] == "2.0" diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_run_samples.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_run_samples.py new file mode 100644 index 000000000000..7a689f9b809c --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_run_samples.py @@ -0,0 +1,395 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline checks for the live sample-test invocation contract.""" + +from contextlib import nullcontext +from copy import deepcopy +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import run_samples +from run_samples import build_command, wait_for_project + + +def ready_state(): + return { + "schemaVersion": 1, + "status": "Ready", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "tenantId": "22222222-2222-2222-2222-222222222222", + "plan": { + "queueName": "q1", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "tenantId": "22222222-2222-2222-2222-222222222222", + "environmentBindings": [ + "AZURE_RESOURCE_GROUP", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TENANT_ID", + "FOUNDRY_AGENT_NAME", + "FOUNDRY_MODEL_NAME", + "FOUNDRY_PROJECT_ENDPOINT", + "MCP_PROJECT_CONNECTION_ID", + ], + "selectedTests": [ + {"node_id": "tests/samples/test_samples.py::TestSamples::test_agent_tools_samples[sample_mcp]"} + ], + }, + "environment": { + "FOUNDRY_PROJECT_ENDPOINT": "https://example.services.ai.azure.com/api/projects/test", + "FOUNDRY_MODEL_NAME": "gpt-4.1", + "FOUNDRY_AGENT_NAME": "sample-test", + "MCP_PROJECT_CONNECTION_ID": "/subscriptions/example/connections/mcp", + "AZURE_SUBSCRIPTION_ID": "00000000-0000-0000-0000-000000000000", + "AZURE_TENANT_ID": "22222222-2222-2222-2222-222222222222", + "AZURE_RESOURCE_GROUP": "example-ci-group", + }, + } + + +def test_command_executes_pytest_live_without_recording(): + state = ready_state() + original = deepcopy(state) + command = build_command(state, Path("results.xml")) + assert command[1:3] == ["-m", "pytest"] + assert state["plan"]["selectedTests"][0]["node_id"] in command + assert "--azure-test-run-live=true" in command + assert "--azure-skip-live-recording=true" in command + assert command[command.index("-p") + 1] == "sample_ci_attachments" + assert "junit_logging=system-out" in command + assert "junit_log_passing_tests=true" in command + assert "--capture=fd" in command + assert "log_cli=false" in command + assert "addopts=" in command + assert "-s" not in command + assert f"--sample-attachments-dir={Path('results.xml').resolve().parent / 'attachments'}" in command + assert "--foundry-model-name=gpt-4.1" in command + assert not any(argument.startswith("--azure-tenant-id") for argument in command) + assert not any("llm-validation-project-endpoint" in argument for argument in command) + assert "--mcp-project-connection-id=/subscriptions/example/connections/mcp" in command + assert state == original + + +def test_function_deployment_outputs_become_live_sample_options(): + state = ready_state() + environment = { + "FUNCTION_APP_NAME": "example-function", + "FUNCTION_APP_URL": "https://example-function.azurewebsites.net", + "STORAGE_INPUT_QUEUE_NAME": "weather-input-queue", + "STORAGE_OUTPUT_QUEUE_NAME": "weather-output-queue", + "STORAGE_QUEUE_SERVICE_ENDPOINT": "https://example.queue.core.windows.net", + } + state["plan"]["environmentBindings"].extend(environment) + state["environment"].update(environment) + command = build_command(state, Path("results.xml")) + for name, value in environment.items(): + assert f"--{name.lower().replace('_', '-')}={value}" in command + + +@pytest.mark.parametrize("status", ["Provisioning", "CleanedUp", "Failed", None]) +def test_incomplete_provisioning_is_rejected(status): + state = ready_state() + state["status"] = status + with pytest.raises(ValueError, match="provisioning must complete"): + build_command(state, Path("results.xml")) + + +def test_no_tests_never_falls_back_to_running_everything(): + state = ready_state() + state["plan"]["selectedTests"] = [] + with pytest.raises(ValueError, match="Only collected"): + build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize("node", ["tests/test_base.py", "-k", "--collect-only", "test_anything", "test\nnext"]) +def test_nodes_cannot_be_pytest_options_or_other_tests(node): + state = ready_state() + state["plan"]["selectedTests"][0]["node_id"] = node + with pytest.raises(ValueError, match="Only collected"): + build_command(state, Path("results.xml")) + + +def test_async_sample_node_is_preserved(): + state = ready_state() + node = "tests/samples/test_samples_async.py::TestSamplesAsync::test_agent_tools_samples_async[sample_mcp_async]" + state["plan"]["selectedTests"][0]["node_id"] = node + assert node in build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize( + "key", + [ + "MCP_GITHUB_PAT", + "PROJ_CONN_OPEN_API_KEY", + "LLM_VALIDATION_PROJECT_ENDPOINT", + "PROJ_CONN_MCP_KEY", + "UNKNOWN_SETTING", + ], +) +def test_unexpected_or_secret_bindings_cannot_become_cli_options(key): + state = ready_state() + state["environment"][key] = "do-not-log-this" + with pytest.raises(ValueError, match="only the expected non-secret"): + build_command(state, Path("results.xml")) + + +def test_binding_declared_by_the_plan_becomes_a_cli_option(): + state = ready_state() + state["plan"]["environmentBindings"].append("NEW_RESOURCE_ENDPOINT") + state["environment"]["NEW_RESOURCE_ENDPOINT"] = "https://example.invalid/new" + assert "--new-resource-endpoint=https://example.invalid/new" in build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize("name", ["MCP_PROJECT_CONNECTION_ID", "FOUNDRY_MODEL_NAME"]) +def test_every_declared_binding_must_be_produced(name): + state = ready_state() + state["environment"][name] = "" + with pytest.raises(ValueError, match="only the expected non-secret"): + build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize( + "bindings", + [None, [], ["FOUNDRY_MODEL_NAME"], "FOUNDRY_MODEL_NAME", ["lowercase", "AZURE_SUBSCRIPTION_ID"]], +) +def test_plan_must_declare_valid_bindings(bindings): + state = ready_state() + if bindings is None: + del state["plan"]["environmentBindings"] + else: + state["plan"]["environmentBindings"] = bindings + with pytest.raises(ValueError, match="must list its environment bindings"): + build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize( + "name", ["MCP_GITHUB_PAT", "PROJ_CONN_OPEN_API_KEY", "PROJ_CONN_MCP_KEY", "ACCESS_TOKEN", "DB_PASSWORD"] +) +def test_plan_cannot_declare_secret_looking_bindings(name): + state = ready_state() + state["plan"]["environmentBindings"].append(name) + state["environment"][name] = "do-not-log-this" + with pytest.raises(ValueError, match="secret-looking"): + build_command(state, Path("results.xml")) + + +@pytest.fixture +def readiness_probe(monkeypatch): + import azure.ai.projects + import azure.identity + + client = SimpleNamespace(agents=SimpleNamespace(list=Mock(return_value=iter(())))) + monkeypatch.setattr(azure.identity, "AzureCliCredential", lambda: nullcontext(object())) + monkeypatch.setattr(azure.ai.projects, "AIProjectClient", lambda *_args: nullcontext(client)) + clock = [0.0] + monkeypatch.setattr(run_samples.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(run_samples.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)) + return client, clock + + +def readiness_error(status, code, message): + from azure.core.exceptions import HttpResponseError + + response = Mock(status_code=status, reason="Readiness error") + response.text.return_value = json.dumps({"error": {"code": code, "message": message}}) + return HttpResponseError(response=response) + + +AGENT_INDEX_TIMEOUT = ( + "Listing agents timed out after 8s while reading the agent index. " + "This usually indicates the project's bring-your-own Cosmos DB endpoint is unreachable or misconfigured." +) + + +def test_agent_index_timeout_is_retried_before_sample_starts(readiness_probe, capsys): + client, clock = readiness_probe + error = readiness_error(400, "invalid_configuration", AGENT_INDEX_TIMEOUT) + client.agents.list.side_effect = [error, iter(())] + wait_for_project("https://example.services.ai.azure.com/api/projects/test", timeout=30) + assert client.agents.list.call_count == 2 + assert clock[0] == 10 + assert "agent-index-timeout" in capsys.readouterr().out + + +@pytest.mark.parametrize("status", [403, 404, 409, 429, 500, 502, 503, 504]) +def test_existing_propagation_errors_are_still_retried(readiness_probe, status): + client, _ = readiness_probe + client.agents.list.side_effect = [readiness_error(status, "Pending", "Not ready"), iter(())] + wait_for_project("https://example.services.ai.azure.com/api/projects/test", timeout=30) + assert client.agents.list.call_count == 2 + + +@pytest.mark.parametrize( + "status,code,message", + [ + (400, "invalid_configuration", "The Cosmos DB connection is missing."), + (400, "invalid_configuration", "Invalid model configuration."), + (400, "BadRequest", AGENT_INDEX_TIMEOUT), + (401, "Unauthorized", "Credentials are invalid."), + ], +) +def test_unrelated_configuration_and_authentication_errors_fail_fast(readiness_probe, status, code, message): + client, clock = readiness_probe + error = readiness_error(status, code, message) + client.agents.list.side_effect = error + with pytest.raises(type(error)) as failure: + wait_for_project("https://example.services.ai.azure.com/api/projects/test", timeout=30) + assert failure.value is error + assert client.agents.list.call_count == 1 + assert clock[0] == 0 + + +def test_persistent_agent_index_timeout_is_bounded_and_does_not_start_pytest(readiness_probe, monkeypatch): + client, clock = readiness_probe + client.agents.list.side_effect = readiness_error(400, "invalid_configuration", AGENT_INDEX_TIMEOUT) + sample_process = Mock(side_effect=AssertionError("Samples must not run before readiness succeeds.")) + monkeypatch.setattr(run_samples.subprocess, "run", sample_process) + with pytest.raises(TimeoutError, match="Project readiness did not succeed"): + wait_for_project("https://example.services.ai.azure.com/api/projects/test", timeout=15) + assert client.agents.list.call_count == 2 + assert clock[0] == 15 + sample_process.assert_not_called() + + +def test_readiness_probe_limits_the_read_request(readiness_probe): + client, _ = readiness_probe + wait_for_project("https://example.services.ai.azure.com/api/projects/test") + client.agents.list.assert_called_once_with(limit=1, connection_timeout=10, read_timeout=30, retry_total=0) + + +@pytest.mark.parametrize( + "value", + [ + "", + "$(LLM_VALIDATION_PROJECT_ENDPOINT)", + "https://example.services.ai.azure.com", + "http://example.services.ai.azure.com/api/projects/validator", + "https://example.services.ai.azure.com/api/projects/validator?token=do-not-log", + " https://example.services.ai.azure.com/api/projects/validator", + ], +) +def test_external_validation_endpoint_is_required_without_echoing_invalid_values(monkeypatch, value): + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", value) + with pytest.raises(ValueError, match="pipeline environment variable") as failure: + run_samples.require_validation_endpoint() + assert "do-not-log" not in str(failure.value) + assert "example.services" not in str(failure.value) + + +def test_endpoint_preflight_never_calls_azure_or_starts_samples(monkeypatch, capsys): + endpoint = "https://external-validator.services.ai.azure.com/api/projects/validator" + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", endpoint) + monkeypatch.setattr(run_samples.sys, "argv", ["run_samples.py", "--check-validation-config"]) + readiness = Mock(side_effect=AssertionError("Configuration preflight must not call Azure.")) + sample_process = Mock(side_effect=AssertionError("Configuration preflight must not start pytest.")) + monkeypatch.setattr(run_samples, "wait_for_project", readiness) + monkeypatch.setattr(run_samples.subprocess, "run", sample_process) + assert run_samples.main() == 0 + assert endpoint not in capsys.readouterr().out + readiness.assert_not_called() + sample_process.assert_not_called() + + +def test_external_validation_endpoint_is_env_only_in_real_runner_invocation(tmp_path, monkeypatch): + endpoint = "https://external-validator.services.ai.azure.com/api/projects/validator" + state = ready_state() + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps(state), encoding="utf-8") + junit = tmp_path / "results.xml" + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", endpoint) + monkeypatch.setenv("AZURE_SUBSCRIPTION_ID", state["subscriptionId"]) + monkeypatch.setenv("AZURE_TENANT_ID", state["tenantId"]) + monkeypatch.setenv("SAMPLE_QUEUE_NAME", "q1") + monkeypatch.setenv("SYSTEM_ACCESSTOKEN", "unit-test-ado-token") + monkeypatch.setenv("MCP_GITHUB_PAT", "github_pat_unit_test_placeholder") + monkeypatch.setenv("PROJ_CONN_OPEN_API_KEY", "unit-test-openapi-key") + monkeypatch.setattr(run_samples.sys, "argv", ["run_samples.py", "--state", str(state_path), "--junit", str(junit)]) + monkeypatch.setattr(run_samples, "wait_for_project", Mock()) + process = Mock(return_value=SimpleNamespace(returncode=0)) + monkeypatch.setattr(run_samples.subprocess, "run", process) + assert run_samples.main() == 0 + command = process.call_args.args[0] + environment = process.call_args.kwargs["env"] + assert environment["LLM_VALIDATION_PROJECT_ENDPOINT"] == endpoint + assert environment["AZURE_SUBSCRIPTION_ID"] == state["subscriptionId"] + assert environment["AZURE_TENANT_ID"] == state["tenantId"] + assert environment["AZURE_TEST_USE_CLI_AUTH"] == "true" + assert environment["AZURE_TOKEN_CREDENTIALS"] == "AzureCliCredential" + assert environment["PYTHON_DOTENV_DISABLED"] == "true" + assert set(json.loads(environment["AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES"])) == set( + state["plan"]["environmentBindings"] + ) + assert "MCP_GITHUB_PAT" not in environment + assert "PROJ_CONN_OPEN_API_KEY" not in environment + assert "SYSTEM_ACCESSTOKEN" not in environment + assert all(endpoint not in argument and "llm-validation-project-endpoint" not in argument for argument in command) + assert endpoint not in state_path.read_text(encoding="utf-8") + assert state["environment"] == ready_state()["environment"] + + +@pytest.mark.parametrize("binding", ["state", "plan", "environment"]) +@pytest.mark.parametrize( + "key,variable,label", + [("subscriptionId", "AZURE_SUBSCRIPTION_ID", "subscription"), ("tenantId", "AZURE_TENANT_ID", "tenant")], +) +def test_runner_rejects_target_mismatch_without_falling_back(binding, key, variable, label): + state = ready_state() + changed = state if binding == "state" else state[binding] + changed[variable if binding == "environment" else key] = "11111111-1111-1111-1111-111111111111" + with pytest.raises(ValueError, match=f"same explicit {label}"): + build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize("value", ["", "$(AZURE_SUBSCRIPTION_ID)", "invalid-subscription", None, 12345]) +@pytest.mark.parametrize("key,label", [("subscriptionId", "subscription"), ("tenantId", "tenant")]) +def test_runner_rejects_missing_or_invalid_target(value, key, label): + state = ready_state() + state[key] = value + with pytest.raises(ValueError, match=f"same explicit {label}"): + build_command(state, Path("results.xml")) + + +@pytest.mark.parametrize("variable", ["AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID"]) +def test_runner_rejects_task_target_change_before_readiness(tmp_path, monkeypatch, variable): + state = ready_state() + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", "https://example.services.ai.azure.com/api/projects/test") + monkeypatch.setenv("AZURE_SUBSCRIPTION_ID", state["subscriptionId"]) + monkeypatch.setenv("AZURE_TENANT_ID", state["tenantId"]) + monkeypatch.setenv("SAMPLE_QUEUE_NAME", "q1") + monkeypatch.setenv(variable, "11111111-1111-1111-1111-111111111111") + monkeypatch.setattr( + run_samples.sys, "argv", ["run_samples.py", "--state", str(state_path), "--junit", str(tmp_path / "result.xml")] + ) + readiness = Mock() + process = Mock() + monkeypatch.setattr(run_samples, "wait_for_project", readiness) + monkeypatch.setattr(run_samples.subprocess, "run", process) + with pytest.raises(ValueError, match=f"task {variable}"): + run_samples.main() + readiness.assert_not_called() + process.assert_not_called() + + +@pytest.mark.parametrize("queue", ["q2", "", "$(QueueName)", "q5"]) +def test_runner_rejects_sibling_queue_before_readiness(tmp_path, monkeypatch, queue): + state = ready_state() + state_path = tmp_path / "state.json" + state_path.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", "https://example.services.ai.azure.com/api/projects/test") + monkeypatch.setenv("SAMPLE_QUEUE_NAME", queue) + monkeypatch.setattr( + run_samples.sys, "argv", ["run_samples.py", "--state", str(state_path), "--junit", str(tmp_path / "result.xml")] + ) + readiness = Mock() + process = Mock() + monkeypatch.setattr(run_samples, "wait_for_project", readiness) + monkeypatch.setattr(run_samples.subprocess, "run", process) + with pytest.raises(ValueError, match="SAMPLE_QUEUE_NAME"): + run_samples.main() + readiness.assert_not_called() + process.assert_not_called() diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_sample_ci_attachments.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_sample_ci_attachments.py new file mode 100644 index 000000000000..c1cbfff5d513 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_sample_ci_attachments.py @@ -0,0 +1,351 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline redaction and real-pytest/JUnit checks; no Azure or MCP requests are made.""" + +import os +import importlib +from pathlib import Path +import re +import subprocess +import sys +from types import SimpleNamespace +import xml.etree.ElementTree as ET + +import pytest + +import sample_ci_attachments as attachments + + +@pytest.fixture +def shared_logs(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1])) + return importlib.import_module("sample_log") + + +@pytest.fixture +def executor_factory(tmp_path, monkeypatch, shared_logs): + executor_module = importlib.import_module("sample_executor") + monkeypatch.setenv("AZURE_TEST_RUN_LIVE", "true") + sample = tmp_path / "sample_log_redaction.py" + sample.write_text("# Offline fixture only.\n", encoding="utf-8") + + def create(directory=tmp_path): + return executor_module.BaseSampleExecutor(object(), str(sample), log_directory=str(directory)) + + return create + + +def test_shared_executor_logs_redact_prints_and_exception_details(executor_factory): + executor = executor_factory() + executor.print_calls = ["Request URL: https://example.test?sig=fixture-signature"] + executor.print_output_calls = ["github_pat_unit_test_placeholder"] + error_log = executor._write_error_log("Bearer unit-test-token", "api-key: fixture-api-key") + output_log = executor._write_output_log() + for path in (error_log, output_log): + text = Path(path).read_text(encoding="utf-8") + for secret in ("unit-test-token", "fixture-api-key", "fixture-signature", "github_pat_unit_test_placeholder"): + assert secret not in text + + +@pytest.mark.parametrize( + "text,secret", + [ + ("Authorization: Bearer unit-test-token", "unit-test-token"), + ("authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz"), + ('{"authorization": "raw-authorization-value"}', "raw-authorization-value"), + ("{'api-key': 'arbitrary-api-key'}", "arbitrary-api-key"), + ('{"client_secret":"json-secret"}', "json-secret"), + ("x-api-key: header-secret", "header-secret"), + (" Cookie: session=sample-cookie", "sample-cookie"), + ('{"Set-Cookie":"session=sample-cookie"}', "sample-cookie"), + ("client_secret=assignment-secret;next=value", "assignment-secret"), + ("AccountKey=account-secret;Endpoint=https://example.test", "account-secret"), + ("https://example.test/data?api-version=2025&sig=sas-secret&ok=true", "sas-secret"), + ("https://example.test/mcp?token=query-secret", "query-secret"), + ("https://user:password-secret@example.test/feed", "password-secret"), + ("github_pat_unit_test_placeholder", "github_pat_unit_test_placeholder"), + ("ghp_unitTestPlaceholder", "ghp_unitTestPlaceholder"), + ("eyJunit.payload.signature", "eyJunit.payload.signature"), + ], +) +def test_shared_log_redaction(text, secret, shared_logs): + result = shared_logs.sanitize_log(text) + assert secret not in result + assert "" in result + + +def test_environment_secrets_are_redacted_in_printed_content(monkeypatch, shared_logs): + monkeypatch.setenv("UNIT_TEST_CLIENT_SECRET", "environment-secret-value") + assert shared_logs.sanitize_log("Printed environment-secret-value") == "Printed " + + +def test_nonsecret_api_details_and_print_content_are_preserved(shared_logs): + text = 'POST https://example.test/responses?api-version=2026\nResponse status: 200\n{"output":"hello 世界"}' + assert shared_logs.sanitize_log(text) == text + + +def test_external_validation_endpoint_is_redacted_from_files(shared_logs, monkeypatch): + endpoint = "https://private-validator.services.ai.azure.com/api/projects/private-validation-project" + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", endpoint) + text = ( + endpoint + + "/openai/responses\nHost: private-validator.services.ai.azure.com\n/api/projects/private-validation-project" + ) + sanitized = shared_logs.sanitize_log(text) + assert endpoint not in sanitized + assert "private-validator" not in sanitized + assert "private-validation-project" not in sanitized + + +def test_attachment_paths_are_isolated_by_full_node_id(tmp_path, executor_factory): + first_executor = executor_factory(attachments.sample_log_directory("test_a[same]", tmp_path)) + second_executor = executor_factory(attachments.sample_log_directory("test_b[same]", tmp_path)) + first_executor.print_output_calls = second_executor.print_output_calls = ["github_pat_unit_test_placeholder"] + first = attachments.sample_attachment_paths([first_executor], tmp_path) + second = attachments.sample_attachment_paths([second_executor], tmp_path) + assert first[0].parent != second[0].parent + assert any("_capture_" in path.name for path in first) + assert any("_output_" in path.name for path in first) + for path in first + second: + assert path.is_relative_to(tmp_path) + assert "github_pat_" not in path.read_text(encoding="utf-8") + assert "no validation result recorded" in first[0].read_text(encoding="utf-8") + + +def test_local_live_logs_keep_their_default_temporary_location(tmp_path, monkeypatch, executor_factory): + executor = executor_factory() + executor.log_directory = None + module = importlib.import_module("sample_executor") + monkeypatch.setattr(module.tempfile, "gettempdir", lambda: str(tmp_path)) + paths = executor.write_capture_logs() + assert all(Path(path).parent == tmp_path for path in paths) + + +def test_no_executors_produces_no_misleading_attachments(tmp_path): + assert attachments.sample_attachment_paths([], tmp_path) == [] + assert list(tmp_path.iterdir()) == [] + + +def test_oversized_logs_are_omitted_without_partial_credentials(tmp_path, monkeypatch, shared_logs, executor_factory): + monkeypatch.setattr(shared_logs, "MAX_LOG_CHARACTERS", 16) + paths = attachments.sample_attachment_paths([executor_factory()], tmp_path) + assert all(path.read_text(encoding="utf-8").startswith("Log omitted") for path in paths) + + +def test_attachment_directory_cannot_inject_junit_markers(tmp_path): + with pytest.raises(ValueError, match="marker delimiters"): + attachments.sample_log_directory("test_a", tmp_path / "injected|marker") + + +def test_attachment_plugin_reuses_existing_executor_files_without_rewriting(tmp_path, executor_factory, monkeypatch): + executor = executor_factory() + executor.print_calls = ["Response status: 200"] + executor.print_output_calls = ["Existing print output"] + executor._write_passed_log("Existing validation result") + expected = [Path(executor.log_file_path), Path(executor.print_output_file_path)] + contents = [path.read_bytes() for path in expected] + + def must_not_rewrite(*_args, **_kwargs): + raise AssertionError("Existing sample log files must be reused.") + + monkeypatch.setattr(executor, "_write_sample_log", must_not_rewrite) + monkeypatch.setattr(executor, "_write_output_log", must_not_rewrite) + assert attachments.sample_attachment_paths([executor], tmp_path) == expected + assert [path.read_bytes() for path in expected] == contents + + +def test_attachment_plugin_rejects_files_outside_run_directory(tmp_path): + outside = tmp_path / "outside.log" + outside.write_text("Not an attachment.\n", encoding="utf-8") + executor = SimpleNamespace(write_capture_logs=lambda: [str(outside)]) + with pytest.raises(ValueError, match="within this run"): + attachments.sample_attachment_paths([executor], tmp_path / "attachments") + + +def test_playback_does_not_create_log_files(tmp_path, monkeypatch, executor_factory): + executor = executor_factory() + monkeypatch.setenv("AZURE_TEST_RUN_LIVE", "false") + assert executor.write_capture_logs() == [] + assert list(tmp_path.glob("*.log")) == [] + + +@pytest.mark.parametrize("failure", [False, True], ids=["passed", "failed"]) +def test_real_sync_async_harness_logs_become_per_result_junit_attachments(tmp_path, failure): + ci_directory = Path(__file__).resolve().parent + sample_directory = ci_directory.parent + source = ( + "import logging\n\n{declaration} main():\n" + " logging.getLogger('azure.ai.projects.openai_transport').debug(\n" + " '==> Request: POST https://example.test/responses\\n'\n" + " 'Authorization: Bearer fake-unit-test-token\\n'\n" + ' \'Body: {{"input":"hello"}}\\n<== Response: 200\')\n' + " print('Sample greeting 世界')\n" + " print('github_pat_unit_test_placeholder')\n" + + (" raise ValueError('Expected offline sample failure')\n" if failure else "") + ) + samples = [] + for name, declaration in (("sample_sync.py", "def"), ("sample_async.py", "async def")): + path = tmp_path / name + path.write_text(source.format(declaration=declaration), encoding="utf-8") + samples.append(str(path)) + test_source = f""" +import asyncio +from contextlib import nullcontext +import pytest +from sample_executor import SyncSampleExecutor, AsyncSampleExecutor + +class OfflineSync(SyncSampleExecutor): + def _get_mock_credential(self): + return nullcontext() + +class OfflineAsync(AsyncSampleExecutor): + def _get_mock_credential(self): + return nullcontext() + +@pytest.mark.parametrize('sample_path', {samples!r}) +def test_sample_output(sample_path): + if sample_path.endswith('sample_async.py'): + executor = OfflineAsync(object(), sample_path) + asyncio.run(executor.execute_async(patched_open_fn=open)) + else: + executor = OfflineSync(object(), sample_path) + executor.execute(patched_open_fn=open) + executor._write_passed_log('Offline validation passed') +""" + test_file = tmp_path / "test_offline_samples.py" + test_file.write_text(test_source, encoding="utf-8") + configuration = tmp_path / "pytest.ini" + configuration.write_text("[pytest]\nlog_cli=true\naddopts=--log-cli-level=DEBUG\n", encoding="utf-8") + report_path = tmp_path / "results.xml" + directory = tmp_path / "attachments" + environment = os.environ.copy() + environment.update( + AZURE_TEST_RUN_LIVE="true", + PYTHON_DOTENV_DISABLED="true", + PYTEST_DISABLE_PLUGIN_AUTOLOAD="1", + PYTEST_ADDOPTS="", + PYTHONIOENCODING="utf-8", + PYTHONPATH=os.pathsep.join((str(ci_directory), str(sample_directory), str(sample_directory.parent))), + ) + process = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(test_file), + "-c", + str(configuration), + f"--confcutdir={tmp_path}", + "-p", + "sample_ci_attachments", + f"--sample-attachments-dir={directory}", + f"--junitxml={report_path}", + "-o", + "junit_logging=system-out", + "-o", + "junit_log_passing_tests=true", + "-o", + "log_cli=false", + "-o", + "addopts=", + "-q", + "--capture=fd", + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + timeout=60, + ) + assert process.returncode == (1 if failure else 0), process.stdout + process.stderr + for output in (process.stdout, process.stderr, report_path.read_text(encoding="utf-8")): + assert "fake-unit-test-token" not in output + assert "github_pat_unit_test_placeholder" not in output + cases = ET.parse(report_path).findall(".//testcase") + assert len(cases) == 2 + parents = set() + for case in cases: + assert (case.find("failure") is not None) is failure + assert case.find("skipped") is None + markers = re.findall(r"\[\[ATTACHMENT\|([^\r\n]+?)\]\]", case.findtext("system-out", "")) + assert len(markers) == 2 + paths = [Path(marker) for marker in markers] + parents.add(paths[0].parent) + api_log = next(path for path in paths if "_output_" not in path.name) + print_log = next(path for path in paths if "_output_" in path.name) + assert ("_errors_" if failure else "_success_") in api_log.name + api_text = api_log.read_text(encoding="utf-8") + print_text = print_log.read_text(encoding="utf-8") + assert "==> Request: POST" in api_text + assert "<== Response: 200" in api_text + assert "Sample greeting 世界" in print_text + assert "Request: POST" not in print_text + assert all("fake-unit-test-token" not in text and "github_pat_" not in text for text in (api_text, print_text)) + assert len(parents) == 2 + + +def test_junit_hook_only_adds_trusted_attachment_markers(shared_logs): + item = SimpleNamespace( + config=SimpleNamespace(getoption=lambda _name: "attachments"), + stash={attachments._ATTACHMENTS: [Path("/safe/log.txt")]}, + ) + report = SimpleNamespace( + when="teardown", + sections=[("Captured stdout call", "[[ATTACHMENT|/outside/secret.txt]]")], + failed=False, + longrepr=None, + ) + hook = attachments.pytest_runtest_makereport(item, None) + next(hook) + with pytest.raises(StopIteration): + hook.send(SimpleNamespace(get_result=lambda: report)) + assert report.sections == [ + ("Captured stdout call", "[[REDACTED-ATTACHMENT|/outside/secret.txt]]"), + ("Captured stdout teardown", f"[[ATTACHMENT|{Path('/safe/log.txt')}]]"), + ] + + +def test_junit_failure_and_captured_output_redact_validation_endpoint(monkeypatch, shared_logs): + endpoint = "https://private-validator.services.ai.azure.com/api/projects/private-validation-project" + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", endpoint) + item = SimpleNamespace(config=SimpleNamespace(getoption=lambda _name: "attachments"), stash={}) + report = SimpleNamespace(when="call", sections=[("Captured stdout call", endpoint)], failed=True, longrepr=endpoint) + hook = attachments.pytest_runtest_makereport(item, None) + next(hook) + with pytest.raises(StopIteration): + hook.send(SimpleNamespace(get_result=lambda: report)) + assert endpoint not in report.longrepr + assert endpoint not in report.sections[0][1] + + +def test_junit_redaction_does_not_depend_on_a_validation_endpoint(monkeypatch, shared_logs): + monkeypatch.delenv("LLM_VALIDATION_PROJECT_ENDPOINT", raising=False) + item = SimpleNamespace(config=SimpleNamespace(getoption=lambda _name: "attachments"), stash={}) + report = SimpleNamespace( + when="call", + sections=[("Captured stdout call", "github_pat_unit_test_placeholder")], + failed=True, + longrepr="Authorization: Bearer fake-unit-test-token", + ) + hook = attachments.pytest_runtest_makereport(item, None) + next(hook) + with pytest.raises(StopIteration): + hook.send(SimpleNamespace(get_result=lambda: report)) + assert "github_pat_" not in report.sections[0][1] + assert "fake-unit-test-token" not in report.longrepr + + +@pytest.mark.parametrize( + "directory,live,has_sample", [(None, "true", True), ("unused", "false", True), ("unused", "true", False)] +) +def test_attachment_capture_is_opt_in(directory, live, has_sample, monkeypatch): + monkeypatch.setenv("AZURE_TEST_RUN_LIVE", live) + node = SimpleNamespace(callspec=SimpleNamespace(params={"sample_path": "unused"}) if has_sample else None) + request = SimpleNamespace(config=SimpleNamespace(getoption=lambda _name: directory), node=node) + capture = attachments._capture_sample_attachments.__wrapped__(request, monkeypatch) + next(capture) + with pytest.raises(StopIteration): + next(capture) diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_search_indexing.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_search_indexing.py new file mode 100644 index 000000000000..3e80f1d2bb06 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_search_indexing.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline tests of the Bicep-managed Search data-plane executor.""" + +import importlib.util +import io +from pathlib import Path +from types import SimpleNamespace +import urllib.error + +import pytest + +spec = importlib.util.spec_from_file_location("search_indexing", Path(__file__).parent / "infra" / "search-indexing.py") +indexing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(indexing) + +ENDPOINT = "https://sample.search.windows.net" + + +def test_token_acquisition_failure_does_not_expose_command_output(monkeypatch): + monkeypatch.setattr( + indexing.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="secret-token") + ) + with pytest.raises(RuntimeError, match="output suppressed") as error: + indexing.DataPlaneClient()._token(indexing.SEARCH_AUDIENCE) + assert "secret-token" not in str(error.value) + + +def test_tokens_are_cached_per_audience(monkeypatch): + calls = [] + + def run(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout=f"token-{len(calls)}") + + monkeypatch.setattr(indexing.subprocess, "run", run) + client = indexing.DataPlaneClient() + assert client._token(indexing.SEARCH_AUDIENCE) == client._token(indexing.SEARCH_AUDIENCE) == "token-1" + assert client._token(indexing.STORAGE_AUDIENCE) == "token-2" + assert len(calls) == 2 + assert all(kwargs not in calls[0] for kwargs in ("role", "login")) + + +@pytest.mark.parametrize( + "url,audience", + [ + ("http://sample.search.windows.net/indexes", indexing.SEARCH_AUDIENCE), + ("https://sample.search.windows.net.evil.invalid/indexes", indexing.SEARCH_AUDIENCE), + ("https://user@sample.search.windows.net/indexes", indexing.SEARCH_AUDIENCE), + ("https://sample.search.windows.net:8443/indexes", indexing.SEARCH_AUDIENCE), + ("https://sample.blob.core.windows.net/documents", indexing.SEARCH_AUDIENCE), + (ENDPOINT, "https://untrusted.invalid"), + ], +) +def test_invalid_endpoint_is_rejected_before_token_acquisition(url, audience, monkeypatch): + client = indexing.DataPlaneClient() + monkeypatch.setattr(client, "_token", lambda audience: pytest.fail("Token must not be acquired")) + with pytest.raises(ValueError, match="token audience"): + client.request("GET", url, audience) + + +def test_redirect_does_not_forward_authorization(): + with pytest.raises(RuntimeError, match="redirects"): + indexing.RejectRedirects().redirect_request(None, None, 302, "", {}, "https://untrusted.invalid") + + +@pytest.mark.parametrize( + "code,method,path", [(403, "GET", "/indexes"), (429, "GET", "/indexes"), (409, "POST", "/indexers/test/run")] +) +def test_transient_http_failure_is_retried_and_logged(code, method, path, monkeypatch, capsys): + client = indexing.DataPlaneClient() + monkeypatch.setattr(client, "_token", lambda audience: "secret-token") + monkeypatch.setattr(indexing.time, "sleep", lambda seconds: None) + calls = [] + + def open_request(request, **kwargs): + calls.append(request) + if len(calls) == 1: + raise urllib.error.HTTPError(request.full_url, code, "", {}, io.BytesIO(b"sensitive-response")) + return io.BytesIO(b'{"value": []}') + + monkeypatch.setattr(client._opener, "open", open_request) + assert client.request(method, indexing.search_url(ENDPOINT, path.lstrip("/")), indexing.SEARCH_AUDIENCE) == { + "value": [] + } + assert len(calls) == 2 + assert "retrying" in capsys.readouterr().out + + +@pytest.mark.parametrize("code", [400, 409]) +def test_permanent_http_failure_is_not_retried_and_body_is_suppressed(code, monkeypatch): + client = indexing.DataPlaneClient() + monkeypatch.setattr(client, "_token", lambda audience: "secret-token") + + def open_request(request, **kwargs): + raise urllib.error.HTTPError(request.full_url, code, "", {}, io.BytesIO(b"sensitive-response")) + + monkeypatch.setattr(client._opener, "open", open_request) + monkeypatch.setattr(indexing.time, "sleep", lambda seconds: pytest.fail("Permanent error must not be retried")) + with pytest.raises(RuntimeError, match=f"HTTP {code}") as error: + client.request("PUT", indexing.search_url(ENDPOINT, "indexes/test"), indexing.SEARCH_AUDIENCE) + assert "sensitive-response" not in str(error.value) + assert "secret-token" not in str(error.value) + + +def test_authorization_retry_has_a_deadline(monkeypatch): + client = indexing.DataPlaneClient() + monkeypatch.setattr(client, "_token", lambda audience: "secret-token") + times = iter([0, 601]) + monkeypatch.setattr(indexing.time, "monotonic", lambda: next(times)) + + def open_request(request, **kwargs): + raise urllib.error.HTTPError(request.full_url, 403, "", {}, io.BytesIO()) + + monkeypatch.setattr(client._opener, "open", open_request) + with pytest.raises(RuntimeError, match="HTTP 403"): + client.request("GET", indexing.search_url(ENDPOINT, "indexes"), indexing.SEARCH_AUDIENCE) + + +def test_indexer_waits_for_a_new_execution(monkeypatch): + results = iter( + [ + {"status": "success", "startTime": "old", "itemsProcessed": 1}, + {"status": "inProgress", "startTime": "new"}, + {"status": "success", "startTime": "new", "itemsProcessed": 1}, + ] + ) + client = SimpleNamespace(request=lambda *args: {"lastResult": next(results)}) + sleeps = [] + monkeypatch.setattr(indexing.time, "sleep", sleeps.append) + indexing.wait_for_indexer(client, ENDPOINT, "sample-indexer", "old") + assert sleeps == [10, 10] + + +@pytest.mark.parametrize( + "result", + [ + {"status": "persistentFailure"}, + {"status": "transientFailure"}, + {"status": "success", "itemsProcessed": 0}, + {"status": "success", "itemsProcessed": 1, "itemsFailed": 1}, + {"status": "success", "itemsProcessed": 1, "warnings": [{"message": "warning"}]}, + {"status": "success", "itemsProcessed": 1, "errors": [{"message": "error"}]}, + ], +) +def test_unsuccessful_indexer_fails_explicitly(result): + client = SimpleNamespace(request=lambda *args: {"lastResult": {"startTime": "new", **result}}) + with pytest.raises(RuntimeError): + indexing.wait_for_indexer(client, ENDPOINT, "sample-indexer", "old") + + +def test_indexer_has_a_deadline(monkeypatch): + times = iter([0, 601]) + monkeypatch.setattr(indexing.time, "monotonic", lambda: next(times)) + with pytest.raises(TimeoutError): + indexing.wait_for_indexer(SimpleNamespace(), ENDPOINT, "sample-indexer", "old") + + +@pytest.mark.parametrize("documents", [[{"chunk": "product content"}], [], [{"chunk": ""}]]) +def test_provision_uploads_definitions_and_runs_both_indexers_before_readiness(documents): + configuration = { + "searchEndpoint": ENDPOINT, + "storageEndpoint": "https://sample.blob.core.windows.net", + "containerName": "documents", + "blobs": [{"name": "product.md", "content": "Markdown"}, {"name": "product.txt", "content": "Text"}], + "index": {"name": "sample"}, + "datasource": {"name": "sample-datasource"}, + "skillset": {"name": "sample-skillset"}, + "indexers": [{"name": "markdown"}, {"name": "documents"}], + } + calls = [] + status_calls = {} + + def request(method, url, audience, body=None, headers=None): + calls.append((method, url, audience, body, headers)) + if url.split("?")[0].endswith("/status"): + count = status_calls.get(url, 0) + status_calls[url] = count + 1 + return {"lastResult": {"status": "success", "startTime": f"run-{count}", "itemsProcessed": 1}} + if url.split("?")[0].endswith("/docs/search"): + return {"value": documents} + return None + + client = SimpleNamespace(request=request) + if documents and documents[0]["chunk"]: + indexing.provision(configuration, client) + else: + with pytest.raises(RuntimeError, match="empty"): + indexing.provision(configuration, client) + assert [call[0] for call in calls[:5]] == ["PUT"] * 5 + assert all(call[2] == indexing.STORAGE_AUDIENCE and call[4]["x-ms-blob-type"] == "BlockBlob" for call in calls[:2]) + assert [call[1].split("?")[0].rsplit("/", 1)[-1] for call in calls if call[0] == "POST"] == [ + "reset", + "run", + "reset", + "run", + "search", + ] diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_select_samples.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_select_samples.py new file mode 100644 index 000000000000..8dac90bd889c --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_select_samples.py @@ -0,0 +1,177 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline tests. Run with --confcutdir=tests/samples/ci to avoid service fixtures.""" + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import select_samples +from select_samples import SampleCollector, SampleTest, matches, normalize, select_tests + +SYNC = SampleTest( + "tests/samples/test_samples.py::TestSamples::test_agent_tools_samples[sample_agent_mcp_with_project_connection]", + "samples/agents/tools/sample_agent_mcp_with_project_connection.py", + "sample_agent_mcp_with_project_connection", +) +ASYNC = SampleTest( + "tests/samples/test_samples_async.py::TestSamplesAsync::" + "test_agent_tools_samples_async[sample_agent_mcp_with_project_connection_async]", + "samples/agents/tools/sample_agent_mcp_with_project_connection_async.py", + "sample_agent_mcp_with_project_connection_async", +) +BASIC = SampleTest( + "tests/samples/test_samples.py::TestSamples::test_agents_samples[sample_agent_basic]", + "samples/agents/sample_agent_basic.py", + "sample_agent_basic", +) + + +@pytest.mark.parametrize( + "pattern,expected", + [ + (SYNC.sample_path, [SYNC]), + (r"samples\agents\tools\sample_agent_mcp*project_connection*.py", [SYNC, ASYNC]), + ("samples/agents/tools/*project_connection_async.py", [ASYNC]), + ("./samples/agents/tools/*project_connection.py", [SYNC]), + ("sdk/ai/azure-ai-projects/samples/agents/tools/*project_connection.py", [SYNC]), + ("SAMPLES/AGENTS/TOOLS/*PROJECT_CONNECTION.PY", [SYNC]), + ], +) +def test_sample_path_wildcards(pattern, expected): + assert select_tests([BASIC, ASYNC, SYNC], "sample-path", pattern) == expected + + +@pytest.mark.parametrize( + "pattern,expected", + [ + (SYNC.node_id, [SYNC]), + (SYNC.test_id, [SYNC]), + ("*test_agent_tools_samples*[*mcp*project_connection*]", [SYNC, ASYNC]), + ("sample_agent_mcp_with_project_connection*", [SYNC, ASYNC]), + ("*test_samples_async.py::*[sample_agent_mcp_with_project_connection_async]", [ASYNC]), + ], +) +def test_node_and_parameter_id_wildcards(pattern, expected): + assert select_tests([ASYNC, SYNC, BASIC], "test-id", pattern) == expected + + +def test_or_queries_deduplicate_and_sort(): + query = f"{SYNC.sample_path}; samples/agents/sample_agent_basic.py\n{SYNC.sample_path}" + assert select_tests([SYNC, BASIC, SYNC], "sample-path", query) == sorted( + [SYNC, BASIC], key=lambda test: test.node_id + ) + + +@pytest.mark.parametrize("query", ["", " ", ";\n;", "*does_not_exist*", "mcp or basic"]) +def test_empty_or_unmatched_query_fails_closed(query): + with pytest.raises(ValueError, match="empty query|No collected sample tests"): + select_tests([SYNC, BASIC], "sample-path", query) + + +def test_unknown_selection_kind_is_rejected(): + with pytest.raises(ValueError, match="Selection kind"): + select_tests([SYNC], "shell", "*") + + +def test_parameter_brackets_are_literal_not_glob_character_classes(): + assert matches("method[sample]", "method[sample]") + assert matches("method[sample]", "method[*]") + assert not matches("methods", "method[sample]") + + +def test_question_mark_matches_one_character_and_regex_is_escaped(): + assert matches("sample_a.py", "sample_?.py") + assert not matches("sample_ab.py", "sample_?.py") + assert not matches("sample_axpy", "sample_?.py") + assert not matches("sample_foo.py", "sample_(foo|bar).py") + + +def test_normalization_preserves_pytest_brackets(): + assert normalize(r".\tests\samples\test_samples.py::Test[variant]") == ( + "tests/samples/test_samples.py::Test[variant]" + ) + + +def test_collector_uses_real_sample_parameter_not_test_name(tmp_path: Path): + sample = tmp_path / SYNC.sample_path + collector = SampleCollector(tmp_path) + session = SimpleNamespace( + items=[ + SimpleNamespace( + nodeid=SYNC.node_id, callspec=SimpleNamespace(params={"sample_path": sample}, id=SYNC.test_id) + ), + SimpleNamespace(nodeid="test_without_sample", callspec=SimpleNamespace(params={}, id="other")), + SimpleNamespace(nodeid="test_not_parametrized"), + SimpleNamespace( + nodeid="empty_parametrization", callspec=SimpleNamespace(params={"sample_path": None}, id="empty") + ), + SimpleNamespace( + nodeid="outside_samples", + callspec=SimpleNamespace(params={"sample_path": tmp_path / "outside.py"}, id="outside"), + ), + ] + ) + collector.pytest_collection_finish(session) + assert collector.tests == [SYNC] + + +@pytest.mark.parametrize("count", [1, 2, 3, 4, 5, 9]) +@pytest.mark.parametrize("requested", [1, 2, 3, 4]) +def test_round_robin_runs_every_selected_case_once(count, requested): + tests = [SampleTest(f"node-{index}", f"sample-{index}.py", str(index)) for index in range(count)] + queues = select_samples.partition_tests(tests, requested) + active = min(count, requested) + assert list(queues) == [f"q{number}" for number in range(1, active + 1)] + for index, shard in enumerate(queues.values()): + assert shard == tests[index::active] + assert shard + all_nodes = [test.node_id for shard in queues.values() for test in shard] + assert len(all_nodes) == len(set(all_nodes)) == count + assert max(map(len, queues.values())) - min(map(len, queues.values())) <= 1 + + +@pytest.mark.parametrize("requested", [0, 5, -1, True, 1.5]) +def test_round_robin_rejects_invalid_worker_count(requested): + with pytest.raises(ValueError, match="one to four"): + select_samples.partition_tests([SYNC], requested) + + +@pytest.mark.parametrize("tests", [[], [SYNC, SYNC]]) +def test_round_robin_rejects_empty_or_duplicate_selection(tests): + with pytest.raises(ValueError, match="nonempty.*unique"): + select_samples.partition_tests(tests, 4) + + +def test_default_pair_creates_two_nonempty_shards_and_dynamic_matrix(tmp_path, monkeypatch): + monkeypatch.setattr(select_samples, "collect_tests", lambda root: [ASYNC, SYNC]) + output = tmp_path / "plan" / "selected-tests.json" + monkeypatch.setattr( + "sys.argv", + [ + "select_samples.py", + "--selection-kind", + "sample-path", + "--selection", + "*", + "--queue-count", + "4", + "--output", + str(output), + ], + ) + assert select_samples.main() == 0 + matrix = json.loads((output.parent / "queue-matrix.json").read_text()) + assert matrix == {"q1": {"QueueName": "q1"}, "q2": {"QueueName": "q2"}} + manifest = json.loads(output.read_text()) + assert [test["node_id"] for test in manifest["tests"]] == [SYNC.node_id, ASYNC.node_id] + for queue, expected in (("q1", SYNC), ("q2", ASYNC)): + shard = json.loads((output.parent / queue / "selected-tests.json").read_text()) + assert [test["node_id"] for test in shard["tests"]] == [expected.node_id] + assert not (output.parent / "q3").exists() + assert not (output.parent / "q4").exists() + with pytest.raises(SystemExit): + select_samples.main() + assert json.loads(output.read_text()) == manifest diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/test_validation_environment.py b/sdk/ai/azure-ai-projects/tests/samples/ci/test_validation_environment.py new file mode 100644 index 000000000000..b2c17df8e256 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/test_validation_environment.py @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# cspell:ignore ispytest +"""Offline regression of CLI-mode conftest configuration without loading service fixtures.""" + +import ast +import json +import os +from pathlib import Path +import sys +import re +from types import SimpleNamespace + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_configuration_environment(monkeypatch): + monkeypatch.delenv("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", raising=False) + monkeypatch.setenv("FOUNDRY_MODEL_NAME", "fixture-model") + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", "fixture-endpoint") + + +def load_cli_environment_hooks(): + """Load the real option registry and hooks without importing proxy fixtures or dotenv.""" + path = Path(__file__).resolve().parents[2] / "conftest.py" + module = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + hooks = [ + node + for node in module.body + if ( + isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "_CLI_ENV_OPTIONS" for target in node.targets) + ) + or ( + isinstance(node, ast.FunctionDef) + and node.name + in {"_get_ci_environment_options", "_has_cli_env_options", "pytest_addoption", "pytest_configure"} + ) + ] + namespace = { + "os": os, + "sys": sys, + "json": json, + "re": re, + "_CLI_ENV_MODE": True, + "_SERVICE_PREPARER_DEFAULTS": { + "llm_validation_project_endpoint": "https://sanitized.services.ai.azure.com/api/projects/sanitized", + "foundry_model_name": "sanitized-model", + }, + } + exec(compile(ast.Module(body=hooks, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def test_future_resource_binding_is_registered_without_a_python_option_mapping(monkeypatch): + monkeypatch.setenv("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", '["NEW_RESOURCE_ENDPOINT"]') + hooks = load_cli_environment_hooks() + parser = pytest.Parser(_ispytest=True) + hooks["pytest_addoption"](parser) + options = vars(parser.parse(["--new-resource-endpoint=https://example.invalid/new"])) + hooks["pytest_configure"](SimpleNamespace(getoption=options.get)) + assert os.environ["NEW_RESOURCE_ENDPOINT"] == "https://example.invalid/new" + + +def test_only_preparer_options_and_mode_flags_are_registered_without_dynamic_names(): + hooks = load_cli_environment_hooks() + assert set(hooks["_CLI_ENV_OPTIONS"]) == set(hooks["_SERVICE_PREPARER_DEFAULTS"]) | { + "azure_test_run_live", + "azure_skip_live_recording", + } + + +@pytest.mark.parametrize("existing", [None, "existing-endpoint"]) +def test_dynamic_options_do_not_inject_sanitized_defaults(monkeypatch, existing): + monkeypatch.setenv("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", '["NEW_RESOURCE_ENDPOINT"]') + if existing is None: + monkeypatch.delenv("NEW_RESOURCE_ENDPOINT", raising=False) + else: + monkeypatch.setenv("NEW_RESOURCE_ENDPOINT", existing) + hooks = load_cli_environment_hooks() + parser = pytest.Parser(_ispytest=True) + hooks["pytest_addoption"](parser) + options = vars(parser.parse([])) + hooks["pytest_configure"](SimpleNamespace(getoption=options.get)) + assert os.environ.get("NEW_RESOURCE_ENDPOINT") == existing + assert "new_resource_endpoint" not in hooks["_SERVICE_PREPARER_DEFAULTS"] + + +@pytest.mark.parametrize("names", ['["ACCESS_TOKEN"]', '["lowercase"]', '{"NAME": "value"}', "[123]", "not-json"]) +def test_invalid_dynamic_option_names_fail_explicitly(monkeypatch, names): + monkeypatch.setenv("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", names) + with pytest.raises(ValueError): + load_cli_environment_hooks() + + +def configure_cli_environment(values): + namespace = load_cli_environment_hooks() + namespace["pytest_configure"](SimpleNamespace(getoption=values.get)) + + +@pytest.mark.parametrize("separator", ["-", "_"]) +def test_function_cli_options_are_registered_and_applied(separator, monkeypatch): + environment = { + "FUNCTION_APP_NAME": "example-function", + "FUNCTION_APP_URL": "https://example-function.azurewebsites.net", + "STORAGE_INPUT_QUEUE_NAME": "weather-input-queue", + "STORAGE_OUTPUT_QUEUE_NAME": "weather-output-queue", + "STORAGE_QUEUE_SERVICE_ENDPOINT": "https://example.queue.core.windows.net", + } + monkeypatch.setenv("AZURE_AI_PROJECTS_TEST_ENVIRONMENT_NAMES", json.dumps(list(environment))) + hooks = load_cli_environment_hooks() + parser = pytest.Parser(_ispytest=True) + hooks["pytest_addoption"](parser) + arguments = [f"--{name.lower().replace('_', separator)}={value}" for name, value in environment.items()] + for name in environment: + monkeypatch.setenv(name, "old-value") + monkeypatch.setattr(sys, "argv", ["pytest", *arguments]) + assert hooks["_has_cli_env_options"]() + options = vars(parser.parse(arguments)) + hooks["pytest_configure"](SimpleNamespace(getoption=options.get)) + for name, value in environment.items(): + assert os.environ[name] == value + + +def test_secret_endpoint_is_not_overwritten_by_preparer_default(monkeypatch): + endpoint = "https://external-validator.services.ai.azure.com/api/projects/validator" + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", endpoint) + configure_cli_environment({"foundry_model_name": "gpt-4.1"}) + assert os.environ["LLM_VALIDATION_PROJECT_ENDPOINT"] == endpoint + assert os.environ["FOUNDRY_MODEL_NAME"] == "gpt-4.1" + + +def test_explicit_local_cli_option_still_has_precedence(monkeypatch): + monkeypatch.setenv("LLM_VALIDATION_PROJECT_ENDPOINT", "existing-env-value") + configure_cli_environment({"llm_validation_project_endpoint": "explicit-cli-value"}) + assert os.environ["LLM_VALIDATION_PROJECT_ENDPOINT"] == "explicit-cli-value" + + +def test_playback_default_remains_available_without_secret(monkeypatch): + monkeypatch.delenv("LLM_VALIDATION_PROJECT_ENDPOINT", raising=False) + configure_cli_environment({}) + assert ( + os.environ["LLM_VALIDATION_PROJECT_ENDPOINT"] + == "https://sanitized.services.ai.azure.com/api/projects/sanitized" + ) + + +def test_other_unspecified_options_still_use_preparer_defaults(): + configure_cli_environment({}) + assert os.environ["FOUNDRY_MODEL_NAME"] == "sanitized-model" diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/validation/IdentityResolution.Tests.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/validation/IdentityResolution.Tests.ps1 new file mode 100644 index 000000000000..ec806ace6a73 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/validation/IdentityResolution.Tests.ps1 @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +. (Join-Path $PSScriptRoot '../SampleCiHelpers.ps1') + +Describe 'Shared identity resolution from a resource group before mutation' { + BeforeEach { + $script:identityGroup = 'rg-azsdk-identities-westus3-102826_164519' + $script:resolutionPlan = @{ + schemaVersion = 1; definitionId = $(if ($env:SYSTEM_DEFINITIONID) { $env:SYSTEM_DEFINITIONID } else { '8392' }) + buildId = $(if ($env:BUILD_BUILDID) { $env:BUILD_BUILDID } else { '12345' }); jobAttempt = '1'; queueName = 'q1' + subscriptionId = '11111111-1111-1111-1111-111111111111'; tenantId = '22222222-2222-2222-2222-222222222222' + ownerToken = '0123456789abcdef0123456789abcdef'; identityResourceGroup = $script:identityGroup + identityResolution = 'Pending'; sharedIdentities = @{}; location = '' + } + $script:resolutionPlan.resourceGroup = 'azsdk-samples-{0}-{1}-q1-1-01234567' -f $script:resolutionPlan.definitionId,$script:resolutionPlan.buildId + $script:metadataIdentities = @(foreach ($purpose in @('project','account','search','function')) { + @{ + id = "/subscriptions/$($script:resolutionPlan.subscriptionId)/resourcegroups/$($script:identityGroup)/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mi-azsdk-sample-${purpose}-westus3-102826_164519" + name = "mi-azsdk-sample-${purpose}-westus3-102826_164519"; type = 'Microsoft.ManagedIdentity/userAssignedIdentities'; location = 'westus3' + properties = @{ tenantId = $script:resolutionPlan.tenantId; principalId = [guid]::NewGuid().ToString() } + tags = @{ purpose = 'azsdk-sample-identities'; 'sample-ci-retention' = 'permanent'; 'sample-ci-owner' = 'abcdef0123456789abcdef0123456789'; 'sample-ci-identity' = $purpose } + } + }) + $script:resolutionNextLink = $null + $script:listUri = "https://management.azure.com/subscriptions/$($script:resolutionPlan.subscriptionId)/resourceGroups/$($script:identityGroup)/providers/Microsoft.ManagedIdentity/userAssignedIdentities" + Mock Invoke-SampleAzure { + param($Arguments,$Sensitive) + if (-not $Sensitive -or $Arguments[0] -cne 'rest' -or $Arguments[2] -cne 'get' -or + $Arguments[4] -cne $script:resolutionPlan.subscriptionId -or + $Arguments[6] -cne ($script:listUri + '?api-version=2023-01-31')) { throw 'Only protected identity-group listing GETs are allowed.' } + return @{ value = $script:metadataIdentities; nextLink = $script:resolutionNextLink } + } + } + + It 'binds the -purpose- identities found in the resource group without changing the pending intent' { + $original = $script:resolutionPlan | ConvertTo-Json -Depth 20 -Compress + $resolved = Resolve-SampleIdentitySelection -Plan $script:resolutionPlan + $resolved.identityResolution | Should BeExactly 'Resolved' + $resolved.location | Should BeExactly 'westus3' + $resolved.sharedIdentities.Count | Should Be 4 + foreach ($identity in $script:metadataIdentities) { + $purpose = $identity.tags['sample-ci-identity'] + $resolved.sharedIdentities[$purpose] | Should BeExactly $identity.id + $resolved.identityPrincipalIds[$purpose] | Should BeExactly $identity.properties.principalId + } + ($script:resolutionPlan | ConvertTo-Json -Depth 20 -Compress) | Should BeExactly $original + Assert-MockCalled Invoke-SampleAzure -Times 1 -Exactly -Scope It + } + + It 'fails closed for missing, duplicate or untrusted metadata: ' -TestCases @( + @{ Fault = 'missing' }, @{ Fault = 'duplicate' }, @{ Fault = 'different group' }, @{ Fault = 'different subscription' }, + @{ Fault = 'different tenant' }, @{ Fault = 'mixed region' }, @{ Fault = 'mixed owner' }, @{ Fault = 'wrong purpose' }, + @{ Fault = 'zero principal' }, @{ Fault = 'malformed principal' }, @{ Fault = 'foreign pagination' }, @{ Fault = 'cycle' }, + @{ Fault = 'wrong API pagination' }, @{ Fault = 'filtered pagination' }, @{ Fault = 'subscription-wide pagination' } + ) { + param($Fault) + switch ($Fault) { + 'missing' { $script:metadataIdentities = @($script:metadataIdentities[0..2]) } + 'duplicate' { $script:metadataIdentities += $script:metadataIdentities[0] } + 'different group' { $script:metadataIdentities[1].id = $script:metadataIdentities[1].id.Replace("/$($script:identityGroup)/",'/other-rg/') } + 'different subscription' { $script:metadataIdentities[1].id = $script:metadataIdentities[1].id.Replace($script:resolutionPlan.subscriptionId,'99999999-9999-9999-9999-999999999999') } + 'different tenant' { $script:metadataIdentities[1].properties.tenantId = '99999999-9999-9999-9999-999999999999' } + 'mixed region' { $script:metadataIdentities[1].location = 'uksouth' } + 'mixed owner' { $script:metadataIdentities[1].tags['sample-ci-owner'] = '0123456789abcdef0123456789abcdef' } + 'wrong purpose' { $script:metadataIdentities[1].tags['sample-ci-identity'] = 'project' } + 'zero principal' { $script:metadataIdentities[1].properties.principalId = '00000000-0000-0000-0000-000000000000' } + 'malformed principal' { $script:metadataIdentities[1].properties.principalId = 'not-an-object-id' } + 'foreign pagination' { $script:resolutionNextLink = 'https://example.invalid/next' } + 'cycle' { $script:resolutionNextLink = "$($script:listUri)?api-version=2023-01-31" } + 'wrong API pagination' { $script:resolutionNextLink = "$($script:listUri)?api-version=2020-01-01" } + 'filtered pagination' { $script:resolutionNextLink = "$($script:listUri)?api-version=2023-01-31&`$filter=anything" } + 'subscription-wide pagination' { $script:resolutionNextLink = "https://management.azure.com/subscriptions/$($script:resolutionPlan.subscriptionId)/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2023-01-31" } + } + { Resolve-SampleIdentitySelection -Plan $script:resolutionPlan } | Should Throw + $script:resolutionPlan.identityResolution | Should BeExactly 'Pending' + $script:resolutionPlan.sharedIdentities.Count | Should Be 0 + Assert-MockCalled Invoke-SampleAzure -Times 0 -Exactly -Scope It -ParameterFilter { $Arguments -contains 'create' -or $Arguments -contains 'delete' } + } + + It 'ignores unrelated identities in the group but not an ambiguous second match' { + $other = @{ id = $script:metadataIdentities[0].id.Replace('-project-', '-unrelated-'); name = 'mi-azsdk-sample-unrelated-westus3-102826_164519' } + $script:metadataIdentities += $other + $resolved = Resolve-SampleIdentitySelection -Plan $script:resolutionPlan + $resolved.sharedIdentities.Count | Should Be 4 + $script:metadataIdentities += @{ id = $script:metadataIdentities[0].id.Replace('-project-', '-project-2-'); name = 'mi-azsdk-sample-project-2-westus3-102826_164519' } + { Resolve-SampleIdentitySelection -Plan $script:resolutionPlan } | Should Throw 'exactly one managed identity' + } + + It 'rejects a principal changed after admission before provisioning resources' { + $resolved = Resolve-SampleIdentitySelection -Plan $script:resolutionPlan + $script:metadataIdentities[0].properties.principalId = [guid]::NewGuid().ToString() + Mock Invoke-SampleAzure { + param($Arguments,$Sensitive) + $identity = @($script:metadataIdentities | Where-Object { $Arguments[-1] -ceq ('https://management.azure.com' + $_.id + '?api-version=2023-01-31') }) + if ($identity.Count -ne 1 -or -not $Sensitive) { throw 'Only exact identity metadata GETs expected.' } + return $identity[0] + } + { Get-SampleSharedIdentities -Plan $resolved } | Should Throw 'resolved identity generation changed' + } +} \ No newline at end of file diff --git a/sdk/ai/azure-ai-projects/tests/samples/ci/validation/Invoke-SampleProvisioning.Tests.ps1 b/sdk/ai/azure-ai-projects/tests/samples/ci/validation/Invoke-SampleProvisioning.Tests.ps1 new file mode 100644 index 000000000000..403491c3f44f --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/ci/validation/Invoke-SampleProvisioning.Tests.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +#Requires -Version 7.2 + +Describe 'Provisioning rejects missing secrets and ineligible regions before mutation' { + It 'creates neither deployment state nor resources: