Compare commits

..

59 Commits

Author SHA1 Message Date
Evan Mattson ae7fa3389c Python: Bump Python package versions for 1.14.0 release (#7661)
* Bump Python package versions for 1.14.0 release

Bump the CHANGELOG-selected packages for the 1.14.0 release: minor versions for root/core, AG-UI, Foundry, OpenAI, and orchestrations due to additive public APIs; patch versions for declarative and GitHub Copilot fixes; and Pacific-date prerelease stamps only for changed alpha/beta packages. No beta cohort bump was applied. Core dependency floors follow the strict policy and remain unchanged because no dependent package requires a new 1.14 API. Release validation also identified and corrected missing AG-UI and Copilot Studio runtime dependencies and aligned GitHub Copilot metadata with its Python 3.11 SDK requirement. Lab is intentionally skipped because its changes are development-only, and the moved Azure Functions and Durable Task packages are documented but no longer versioned here.

* Raise AG-UI core dependency floor
2026-08-14 11:06:35 +09:00
Evan Mattson 4aa737eee5 Python: [BREAKING] Require building functional workflow instances (#7521)
* Harden functional workflow continuation authority

Use a versioned opaque single-use token on WorkflowRunResult, validate it before request correlation, consume it immediately before replayed user code, and rotate it on each pause. Carry the same explicit authority through streaming and non-streaming FunctionalWorkflowAgent responses.

Files changed: functional workflow/runtime result APIs, functional HITL regression tests, core agent guidance, and the functional HITL sample.

Next iteration: enforce pending-state overlap and token-authorized abandonment, then document and test checkpoint authorization boundaries.

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

* Enforce one pending functional continuation

Reject fresh messages and checkpoint restores while an in-memory continuation is pending. Add token-authorized abandonment on FunctionalWorkflow and FunctionalWorkflowAgent, and clear retained replay state atomically when authority is consumed while preserving the active message for token rotation and checkpoints.

Files changed: functional workflow runtime and agent adapter, functional lifecycle regression tests, and core workflow guidance.

Next iteration: preserve and document authorized checkpoint continuation boundaries.

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

* Preserve authorized functional checkpoint continuation

Treat checkpoint restore as a host- and storage-authorized path independent of process-local continuation tokens, and issue fresh authority whenever restored execution pauses again. Cover default and per-run storage, deterministic and custom request IDs, token rotation, and checkpoint-plus-response restore.

Files changed: functional workflow and checkpoint interface guidance, functional checkpoint lifecycle tests, the functional HITL sample, and core workflow guidance.

Next iteration: run the final repository-wide Python validation gates.

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

* Validate Python continuation hardening

Run the complete Python workspace checks, aggregate coverage suite, repository hooks, and core package build from the final combined worktree. Keep the validation iteration code-neutral because all gates pass without corrective changes.

Files changed: none; this commit records the final validation gate.

Blockers: none. Next iteration: no remaining AFK tasks.

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

* Handle functional checkpoint continuation failures

Publish retained continuation state only after checkpoint persistence succeeds, and cover reuse after a transient save failure.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Address functional continuation review findings

Add owner recovery for lost tokens, harden malformed token validation, preserve consistent failure surfaces, and keep agent pending state aligned with resumable workflow state.

Document process-local single-use continuation semantics and extend regression coverage across direct, streaming, checkpoint, and agent paths.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Handle functional continuation cancellation

Release the workflow run guard when cancellation interrupts resumed user code while keeping the single-use continuation token consumed.

Replace sample assertions with explicit runtime checks and add cancellation regression coverage.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Simplify functional workflow instance isolation

Remove continuation-token handling and align functional workflows with the graph workflow ownership model: one stateful instance per logical caller or session.

Add create_instance() for independent callers, document the ownership contract, and cover pending-state isolation between instances.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Scope functional workflow checkpoint storage

Do not inherit checkpoint storage when creating an independent workflow instance. Allow hosts to provide an explicitly caller-scoped storage adapter and document that shared checkpoint access requires host authorization and tenant isolation.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Require building functional workflow instances

Make @workflow return a stateless FunctionalWorkflowDefinition and require build() before run() or as_agent(). This aligns functional workflows with the graph definition/build lifecycle and prevents module-level decorated definitions from retaining caller state.

Move checkpoint configuration to build(), export the definition type, migrate samples, and cover isolated built instances.

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

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78
2026-08-14 00:31:52 +00:00
Evan Mattson 8c4da3c3b9 Python: Harden AG-UI approval lifecycle and resume semantics (#7594)
* Route local approvals through lifecycle owner

Key decisions:
- Add an internal typed approval lifecycle with pending, claimed, executing, and settled states.
- Keep authorization separate from execution; only LocalPendingToolTransitionOwner invokes approved local calls.
- Register server-owned occurrences before canonical ResumeDecision claims and retain one replayable result under the original call identity.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_approval_state.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_approval_result_event.py
- packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py

Verification:
- 952 AG-UI tests passed.
- Focused lifecycle/public tracer passed with warnings treated as errors.
- Ruff format/check and AG-UI Pyright passed.
- git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping is inaccessible under the organization content-exclusion policy and could not be updated.
- The workspace Poe package fan-out is blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks were run.

* Make approval batches occurrence-safe

Key decisions:
- Give each local approval a scoped logical occurrence identity and share one occurrence across trusted thread aliases.
- Validate complete Resume Decision batches before applying claims, then account for accepted, rejected, and cancelled occurrences independently.
- Preserve sibling authority and original result identity across failures, mixed decisions, and reused raw call IDs.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_approval_state.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py

Verification:
- 959 AG-UI tests passed with 90% lifecycle branch coverage.
- 30 focused lifecycle/public tracer tests passed with warnings treated as errors.
- Ruff format/check and AG-UI Pyright passed.
- git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace typing fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; package-local Pyright passed, while package-local MyPy retains three unrelated baseline errors.

* Make approval resume retries idempotent

Key decisions:
- Retain terminal decisions and outcomes by scoped occurrence so identical accepted and rejected retries reproject results without granting execution authority again.
- Reject conflicting names, arguments, decisions, wrong-scope lookups, and expired authority before an execution intent can reach the local transition owner.
- Keep protocol normalization in the runner while using server-owned lifecycle context to canonicalize retries and preserve existing AG-UI wire aliases.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py
- packages/ag-ui/tests/ag_ui/test_endpoint.py

Verification:
- 965 AG-UI tests passed with 90% approval lifecycle coverage.
- 18 focused lifecycle, hostile-resume, wrong-thread, and endpoint retry tests passed with runtime and deprecation warnings treated as errors.
- Ruff format/check and AG-UI package-local Pyright passed.
- git diff --check passed.

Notes for next iteration:
- Terminal retention is process-local and unbounded until the later bounded-retention issue adds its explicit policy.
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.

* Separate approval execution ownership

Key decisions:
- Carry explicit local, hosted, deferred in-run, or unavailable ownership on every approval occurrence and authorized intent.
- Keep lifecycle authorization separate from execution; local calls execute only through the local adapter while hosted and setup-injected decisions forward through owner-specific adapters.
- Leave declaration-only calls pending when no transition owner can act, and settle forwarded outcomes against the original occurrence without local fallback.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_approval_state.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_endpoint.py

Verification:
- 967 AG-UI tests passed with 92% package coverage and 89% approval lifecycle coverage.
- 94 focused lifecycle, hosted, deferred-owner, hostile-resume, and approval tests passed.
- Ruff format/check and package-local Pyright passed.
- git diff --check passed.

Notes for next iteration:
- Executing-without-outcome recovery remains for the indeterminate execution-window issue.
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace Poe fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks passed.

* Represent approval execution uncertainty

Key decisions:
- Distinguish reserved claims from execution windows that may have started an external side effect.
- Recover non-idempotent execution failures as indeterminate and reject identical retries without another invocation.
- Permit claim release only under an explicit safe policy and execution retry only with a predeclared idempotency key shared by local and forwarded owners.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_endpoint.py

Verification:
- 972 AG-UI tests passed with 92% line coverage and 89% package branch coverage.
- 23 focused lifecycle, duplicate-resume, hosted-owner, and public settlement-window tests passed.
- Package-local Ruff and Pyright passed; git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace Poe fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks passed.

* Reconcile approval snapshots with lifecycle state

Key decisions:
- Keep Approval State authoritative and emit typed snapshot reconciliation keyed by logical occurrence identity.
- Retire settled, rejected, cancelled, expired, indeterminate, and missing controls while preserving nonterminal authority.
- Reconcile stale snapshots before hydration or resume, and retain lifecycle deduplication when snapshot saves fail.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/agent_framework_ag_ui/_snapshot_session.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_endpoint.py

Verification:
- 975 AG-UI tests passed with 92% package coverage and 89% approval lifecycle coverage.
- Package-local Ruff and Pyright passed; git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace Poe fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks passed.

* Bound process-local approval lifecycle state

Key decisions:
- Protect pending, claimed, executing, and indeterminate occurrences from eviction while retaining terminal outcomes for a configurable 15-minute process-local deduplication window.
- Serialize complete approval batches by logical occurrence locks so aliases share atomic decisions and independent batches can progress concurrently.
- Fail capacity, claim, and settlement conflicts explicitly, and emit redacted structured lifecycle telemetry without tool names, arguments, or approval payloads.
- Remove legacy LRU eviction paths so active Approval State and middleware state are never silently discarded.

Files changed:
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_approval_state.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_approval_state.py

Verification:
- 982 AG-UI tests passed with 92% package coverage and 91% approval lifecycle coverage.
- 34 focused lifecycle and storage tests passed with RuntimeWarning and DeprecationWarning treated as errors.
- Package-local Ruff and Pyright passed; git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace Poe fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks passed.

* Complete approval lifecycle cutover

Key decisions:
- Make ApprovalLifecycle the sole owner of trusted aliases, occurrence metadata, authority transitions, and retained outcomes.
- Remove the parallel mutable pending-approval registry and route local, hosted, deferred, cancellation, replay, and snapshot reconciliation through lifecycle occurrences.
- Encapsulate middleware Approval State behind copy-isolated store methods while keeping AG-UI protocol normalization and event projection in the runner.

Files changed:
- packages/ag-ui/AGENTS.md
- packages/ag-ui/agent_framework_ag_ui/_agent.py
- packages/ag-ui/agent_framework_ag_ui/_agent_run.py
- packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py
- packages/ag-ui/agent_framework_ag_ui/_approval_state.py
- packages/ag-ui/tests/ag_ui/test_agent_wrapper_comprehensive.py
- packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py
- packages/ag-ui/tests/ag_ui/test_approval_result_event.py
- packages/ag-ui/tests/ag_ui/test_approval_state.py
- packages/ag-ui/tests/ag_ui/test_endpoint.py
- packages/ag-ui/tests/ag_ui/test_run.py

Verification:
- 964 package-local AG-UI tests passed with 92% coverage and 90% approval lifecycle coverage.
- 85 warning-strict focused approval tests passed.
- Package-local Ruff and Pyright passed; git diff --check passed.

Notes for next iteration:
- The function-calling-loop scenario mapping remains inaccessible under the organization content-exclusion policy.
- Workspace Poe fan-out remains blocked by the pre-existing missing packages/durabletask/pyproject.toml; equivalent package-local checks passed.

* Align AG-UI approval resumes with protocol

* Align workflow approvals with AG-UI resumes

* Address AG-UI approval review findings

* fix AG-UI test typing checks

* fix AG-UI approval retention and cancellation retries
2026-08-14 00:28:14 +00:00
Evan Mattson 5fafa18569 Python: track agent-hooks feature usage (#7558) 2026-08-14 00:06:33 +00:00
Tao Chen ee27065359 Python: Update agentserver to x.1.0b1 (#7621)
* Update agentserver to 2.1.0

* Update agentserver responses and invocations to x.1.0b1

* Pass platform context to state store provider

* Pass user id

* Correct requirements.txt

* Fix unit tests

* Fix unit tests
2026-08-14 00:02:17 +00:00
Atharva Vichare 9645d33cde Python: Fix Cosmos memory provider calling renamed add_cosmos toolkit API (#7635)
* Python: Fix Cosmos memory provider calling renamed add_cosmos toolkit API

The Agent Memory Toolkit renamed AsyncCosmosMemoryClient.add_cosmos to
upsert_memory with an identical signature. The provider declares
azure-cosmos-agent-memory>=0.2.0b3 with no upper bound, so a resolved
install can expose either name. after_run swallows write errors and only
logs a warning, so on a post-rename toolkit the agent turn still looks
successful while long-term memory silently stops receiving turns.

Resolve the write method once per after_run, preferring upsert_memory and
falling back to add_cosmos, so both ends of the declared range keep working.
Same treatment for the emulator test's direct seed call.

Fixes #7633

* Ponytail comment erased

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

* Clarify TODO comment regarding memory method rename

Updated TODO comment to include author and clarify context , to resolve linting error

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-13 21:25:38 +00:00
pratik wayase 9a06fa3f42 Python: fix(python): add release_session API to prevent BackgroundAgentsProvider memory leaks (#7450)
* fix: add release_session API to prevent BackgroundAgentsProvider memory leaks

* fix: address Copilot review comments on release_session

* fix(harness): make background agent session release race-safe and bounded

* fix (harness): address release_session and review feedback
2026-08-13 19:35:36 +00:00
westey 6d25fb1e9c Remove clear and package source mapping from nuget.config to allow user level config inheritance (#7646) 2026-08-13 16:09:41 +00:00
westey e926ad2859 Python: fix streaming transcript duplication with message injection and per-service-call persistence (#7605)
* Fix ordering issue when streaming with content injection and per-service-call persistence

* Update spec

* Address PR comment

* revert uv.lock changes
2026-08-13 15:04:58 +00:00
Giles Odigwe 7cfa905486 Python: scope under-specified approve-for-session permission decisions (#7607)
* Python: scope under-specified approve-for-session permission decisions

PermissionDecisionApproveForSession carries an optional `approval` (tool
prompts) and an optional `domain` (URL prompts), so it can be constructed
with neither. A bare PermissionDecisionApproveForSession() serializes to
{"kind": "approve-for-session"}, which the Copilot CLI cannot interpret: it
dereferences the absent approval and crashes the CLI process with "Cannot
read properties of undefined (reading 'commandIdentifiers')", taking the
whole run down rather than failing a single tool call.

Wrap the resolved permission handler so such decisions are scoped using the
request that triggered them: shell prompts become an approval for that
prompt's command identifiers, MCP prompts an approval for that server and
tool, URL prompts an approval for that URL's domain, and so on.

The decision is only ever narrowed, never widened. When the prompt reports
can_offer_session_approval=False, or the request kind has no session-scoped
approval (such as a hook prompt), the decision is downgraded to a single-use
approval and a warning is logged. Decisions that already specify a scope are
forwarded unchanged, and handler exceptions still propagate so the SDK's
deny-on-error behavior is preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b45752e-b602-4117-8304-3c8a8b877e3e

* Fix test-suite type-checker errors for permission-decision normalizer

The permission-handler wrapper returned PermissionHandlerType (the sync-or-async
union), so awaiting its result in tests was rejected by the stricter CI type
checkers (pyrefly, ty, zuban). Give the wrapper a dedicated
AsyncPermissionHandlerType return type, and narrow the awaited result with an
isinstance assert before accessing its scope in the async-handler test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b45752e-b602-4117-8304-3c8a8b877e3e

* Add regression tests for extension permission approval normalization

Cover the two previously-untested branches of _derive_session_approval:
extension-management preserves the request operation, and
extension-permission-access preserves the extension name. Both assert the
serialized approval payload as well.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b45752e-b602-4117-8304-3c8a8b877e3e

* Scope URL session approvals only for parser-unambiguous URLs

The URL branch derived the persisted domain with Python's urlparse, but the
Copilot CLI parses URLs with WHATWG semantics. The two disagree on crafted
authorities -- e.g. a backslash before the '@' in
'https://example.com<backslash>@evil.com' resolves to example.com under the CLI
but evil.com under urlparse -- so trusting urlparse could persist a session-wide
approval for an unrelated, attacker-chosen domain, widening authorization.

Add _derive_url_session_domain, which returns a domain only when the URL
contains none of the characters WHATWG and urlparse handle differently
(backslash, tab, newline, carriage return); any ambiguity (or a URL with no
host) narrows the decision to a single-use PermissionDecisionApproveOnce.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b45752e-b602-4117-8304-3c8a8b877e3e

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b45752e-b602-4117-8304-3c8a8b877e3e
2026-08-13 01:13:19 +00:00
Ruiming Zhao 3221011427 fix(core): warn when advertised MCP archives are rejected (#7622) 2026-08-13 00:44:55 +00:00
Chinmay V 3aadac53c8 Python: fix(redis): honour a max_messages retention limit of zero (#7470)
* Python: fix(redis): honour a max_messages retention limit of zero

RedisHistoryProvider documents None as the sentinel for unlimited storage,
so max_messages=0 must retain nothing. It retained everything: trimming to
-max_messages emits LTRIM key 0 -1, which is Redis's "keep the whole list",
and the count > max_messages guard is true for any non-empty list, so the
trim ran on every save and did nothing.

Negative values were worse than a no-op. max_messages=-5 emitted
LTRIM key 5 -1, deleting the five oldest messages on every save while the
list still grew without bound.

Handle a limit of zero by deleting the key, which is what clear() in this
class already does, and reject negative values in __init__ alongside the
three ValueErrors it already raises for invalid configuration. None and
positive limits are unchanged.

* Python: never write the payload when Redis retention is disabled

Addresses the automated review on #7470. With max_messages=0 the previous
change still RPUSHed every message and deleted the key afterwards, so the
payload reached Redis - and any AOF or replica stream - before being removed,
and was briefly visible to other readers. Short-circuit instead: drop any
existing history and return before serializing, so nothing is written at all.

Also documents the new ValueError in the Raises: section, and asserts in the
test that the pipeline is never used.

* Python: leave stored history alone when Redis retention is disabled

max_messages=0 deleted the session key. _redis_key omits source_id, so
two providers with the default prefix share {key_prefix}:{session_id},
and the after-run pass persists in reverse provider order - a
zero-retention provider listed first would drop a co-located provider's
just-written history on every turn.

Return before serializing instead: no payload reaches Redis, an AOF or a
replica, and stored history is left as it is. Removing stored history is
what clear() is for.

---------

Co-authored-by: Chinmay V <203952148+chinmayv095@users.noreply.github.com>
2026-08-13 00:44:20 +00:00
Ruiming Zhao 35c6b880f7 Python: Preserve Mistral prompt-cache usage details (#7597)
* Fix Mistral cached token usage

Map prompt cache hits from Mistral chat usage into the standard usage details. Add regression coverage for regular and streaming responses.

* Validate Mistral cached token usage

* fix(mistral): satisfy strict cached token typing

Narrow prompt token details before reading cached_tokens so the Mistral package passes strict Pyright without changing runtime validation.\n\nAddresses https://github.com/microsoft/agent-framework/pull/7597#discussion_r3750712320
2026-08-13 00:43:50 +00:00
Vaibhav Patel 3a5d00be54 Python: add checkpointing support to AgentFrameworkWorkflow.run() in agent-framework-ag-ui (#6646)
* Python: add checkpointing support to AgentFrameworkWorkflow.run() in ag-ui

The ag-ui AgentFrameworkWorkflow.run() previously accepted only a
RunAgentInput payload and exposed no way to use the core workflow's
checkpointing/state-persistence, unlike the core agent-framework workflow
implementations. This left ag-ui workflows without resumable execution.

Add optional checkpoint_storage and checkpoint_id keyword arguments to
run(), threaded through run_workflow_stream() into the core Workflow.run().
This delegates to the existing core capability instead of reinventing it and
keeps the public surface consistent with Workflow.run():

- checkpoint_storage enables checkpoint creation at each superstep boundary.
- checkpoint_id resumes a run from a persisted checkpoint; incoming messages
  are forwarded only as request-info responses (never as a new start-executor
  message) to honor the core's message/checkpoint_id mutual exclusivity, and
  responses + checkpoint_id performs a restore-then-send in one call.

Both can also be supplied via the input_data keys __ag_ui_checkpoint_storage
and __ag_ui_checkpoint_id so the FastAPI endpoint (which calls run(input_data)
positionally) can opt in without changing its call site; explicit keyword
arguments take precedence. Checkpoint resume bypasses the AG-UI thread snapshot
hydration early-returns so it always reaches the core restore path.

Backward compatible: run(input_data) keeps working unchanged, and the
non-checkpoint path still calls run_workflow_stream(input_data, workflow) with
its original two-argument convention. Adds focused tests covering checkpoint
creation, resume-from-checkpoint, input-data-keyed params, and the unchanged
default path.

Fixes #6632.

* Import Executor from the public agent_framework API in ag-ui workflow test

* Fix ag-ui checkpoint resume: preserve thread snapshot, coerce resume responses; fix CI lint/typing

A checkpoint-only resume no longer clobbers the stored AG-UI thread snapshot:
the snapshot builder is seeded with the prior stored history so the saved
snapshot keeps the earlier replayable transcript plus the newly produced output.

Resume responses are now coerced against the post-restore pending requests on a
checkpoint restore, so a JSON function_approval_response resumes through AG-UI
after a cold restore instead of failing with a response-type mismatch.

Also update the test-double workflow run() overrides to match the new keyword-only
parent signature and re-sort the workflow test imports so ruff and the typing
checkers pass.

* Coerce ag-ui resume responses without a second checkpoint restore

Reading pending request_info events for resume-response coercion previously
restored the checkpoint into the live workflow, which invoked every executor's
on_checkpoint_restore hook. workflow.run(checkpoint_id=...) then restored again,
running those hooks a second time. Custom restore hooks are not required to be
idempotent, so this could duplicate restoration work or break workflows that
expect exactly one restore per resume.

Load the persisted WorkflowCheckpoint directly from storage (runtime override
or the workflow's build-time context storage) and read its
pending_request_info_events instead. This exposes the same post-restore pending
set for the resume contract and response coercion without mutating workflow
state or running any restore hook, leaving workflow.run(checkpoint_id=...) as
the single restore per resume.

Add a regression test asserting on_checkpoint_restore runs exactly once on a
checkpointed ag-ui resume.

* Python: rework AG-UI workflow checkpointing onto public configuration surfaces

Checkpoint storage is now configured on AgentFrameworkWorkflow (or the
FastAPI endpoint) instead of being smuggled through input_data keys, and
a run resumes by supplying its checkpoint id in the AG-UI forwarded
props. With storage always in hand, resume-response coercion reads the
pending request set straight from the persisted checkpoint via the
public CheckpointStorage.load(), replacing the private runner-context
fallback, and the core run call forwards checkpoint arguments directly,
relying on core validation for conflicting parameters. Requesting a
resume without configured storage now fails with a clear error.

* Assign endpoint checkpoint storage in a single place

The raw-workflow branch assigned checkpoint_storage at construction and
the wiring block assigned it again. Construct the wrapper bare and let
the wiring block own the assignment; the existing-storage guard keeps
allowing a pre-wrapped runner without storage to adopt the endpoint's.

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
2026-08-12 22:07:52 +00:00
westey 56d13bce4e .NET: Add BackgroundAgentsProvider.ReleaseSessionAsync to cancel and release per-session background tasks (#7602)
* Add the abilty for the caller to release and cancel background tasks

* Improve param validation

* Address PR comments

* Address PR comments.

* Address PR comments: cancel tasks before publishing the release

Set IsReleased and publish the ReleaseCompletion only after the in-flight
tasks have actually been cancelled, so a failure to cancel leaves the
session un-released instead of flagging it as released while its tasks are
still running.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-12 14:25:59 +00:00
Luis Rodriguez 27d82b1567 Python: Ignore non-project workspace glob matches (#7509)
* Python: Ignore non-project workspace glob matches

* test: collect workspace script tests

* test: remove standalone script test

---------

Co-authored-by: Luis Rodriguez <25299418+luisangelrod@users.noreply.github.com>
Co-authored-by: Luis Rodriguez <luis.rodriguez@bcpos.com>
2026-08-11 19:14:18 +00:00
Giles Odigwe 5e52c6a718 Python: Fix ClaudeAgent reusing one SDK client across distinct fresh sessions (#7404)
* Python: Fix ClaudeAgent reusing one SDK client across distinct fresh sessions

RawClaudeAgent kept a single mutable ClaudeSDKClient on the agent instance
and reused it across distinct fresh AgentSession objects, because a fresh
session passes session_id=None and the old reuse check treated that as
"keep the current client". Two independent fresh sessions on one shared
agent instance therefore shared a single provider conversation, so the
second session continued the first session's conversation.

Treat a fresh (None) continuation id as always requiring a new client, so
an unbound session never inherits an existing provider conversation.
Legitimate continuity is preserved: once a session runs, its
service_session_id is written back, so later runs pass a real id and resume
correctly. Guard client selection/creation with an asyncio.Lock so
concurrent runs cannot race between the check and the client assignment.

Add regression tests asserting two fresh sessions produce two clients and
that an explicit continuation id still resumes the existing client.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af

* Python: Bind Claude SDK client ownership to each run

Replace the single mutable ClaudeSDKClient stored on the agent with a
per-run client. Because a ClaudeSDKClient represents exactly one provider
conversation, sharing one across distinct sessions collapsed them onto the
same conversation and, for concurrent runs, let a fresh session disconnect
a client another run was still streaming from.

_acquire_client now returns a per-run client (owned) that resumes the
framework session's provider conversation when one exists, and _get_stream
releases it in a finally once the run completes. An injected client is
reused verbatim and left to the caller. The streaming loop moves into
_stream_run so the client is a local per-run value rather than shared agent
state, which keeps distinct sessions isolated even under concurrency.

Continuity is preserved: a session's service_session_id is written back
after each run and forwarded as the resume id on subsequent runs. Replace
the client-lifecycle tests with per-run ownership and end-to-end isolation
tests (two fresh sessions get two separate clients, each disconnected).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af

* Python: Close remaining Claude session-isolation gaps

Address three shared-state gaps in the Claude adapter surfaced in review:

- Run-scope structured output: carry the run's structured_output through a
  per-run state holder and a per-run finalizer instead of storing it on the
  agent, so a concurrent run cannot overwrite another run's value before its
  finalizer reads it.
- Bind an injected client to one session: an injected ClaudeSDKClient is a
  single Claude conversation, so bind it to the first session that uses it and
  raise AgentInvalidRequestException if a different session tries to reuse it.
  A no-session run reuses the bound session so multi-turn continuity still
  works; multi-session callers must omit client= or use one agent per session.
- Serialize the injected-client path with an asyncio.Lock so concurrent runs
  cannot race its connect or interleave queries on the one shared client.
  Owned per-run clients stay lock-free.

Update and extend the tests accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af

* Python: Bind injected Claude client on provider conversation identity

Compare an injected client's binding on the session's service_session_id
(the Claude conversation identity) rather than the framework-local
session_id, falling back to session_id only when the incoming session has
no provider id yet. A reconstructed session from
get_session(service_session_id=...) carries a fresh session_id but the same
provider conversation, so it now continues the bound conversation instead of
raising. Sessions targeting a different conversation are still rejected.

Add regression tests for reconstructed-same-conversation continuation and
different-conversation rejection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af
2026-08-11 19:04:08 +00:00
Giles Odigwe 30996433ac Python: Restore Gemini thought_signature on approval replays (#7546)
Gemini 3.x rejects a request whose functionCall parts lack a
thought_signature. The signature was carried as base64 protected_data on a
text_reasoning content and re-attached by adjacency, which requires the
carrier to immediately precede its call. An approval round trip replays the
call with no carrier at all, so the next turn failed with a 400.

Track signatures in a bounded per-client call_id map populated at parse time
from the resolved call_id, and backfill only when the emitted part has no
signature. Also stop clearing the held signature on contents that emit no
Part, so an approval response or an unsigned thought summary between the
carrier and its call no longer drops it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0909cd-c7c3-42cb-aef1-1e9a3e64d917
2026-08-11 19:03:57 +00:00
Tao Chen e85b3c8ba8 Python: Fix FHA session ID translation (#7608)
* Fix FHA session ID traslation

* Fix tests

* Address comments and fix tests

* Fix typing

* Show how to use user created sessions

* Update README
2026-08-11 17:30:56 +00:00
Roger Barreto 4ca093371e .NET: Add Options for Hosted Agent to Allow Backend Storage (#7572)
* Let the container choose who stores a hosted turn, and say so when it is stored twice

Turning storage off downstream was unconditional and silent. It is now a container choice, and a
deployment that ends up storing anyway is reported instead of quietly recording the conversation in
two places nothing reconciles.

FoundryResponsesOptions, passed through AddFoundryResponses, carries two settings.
AllowStoredOutputEnabled defaults to false, which is when hosting turns storage off for every run and
checks the result. Setting it to true leaves the agent's own configuration exactly as the container
built it, and nothing is checked, overridden, or refused. IncludeReasoningEncryptedContent applies
while storage is off, asking for the encrypted form of the reasoning tokens so reasoning survives
between turns, mirroring AsIChatClientWithStoredOutputDisabled.

Two checks replace the 400 that used to refuse a session carrying a conversation id. The readiness
probe runs each registered agent with its chat client swapped for one that calls nothing, so the
request the agent builds on its own is visible without leaving the container, and an agent asking for
its responses to be stored keeps the container out of rotation. Per request, a conversation id on the
session after the run means the agent's own service kept the turn, which fails with 501 and leaves
the session unsaved so later turns do not resume onto it. A misconfigured container is a server
problem, not a bad request, hence 5xx.

Only a confirmed "this asks to be stored" fails either check. An agent that is not a ChatClientAgent,
a request shape carrying no such setting, and a run that could not be completed all pass: this
package cannot tell what those would do.

* Rename the stored-session flag to say what it means

* Say plainly what server-side storage does to a hosted turn

* Read the store gate as an allow, and align the messages

The flag that decides whether the session may be saved reads as an allow at every use, while the
test it comes from keeps saying what is not allowed, so neither side has to be read inside out.

The wording now matches what the readiness probe says: server side storage must be off, because with
it on the agent's own service records a conversation and response nothing tracks while the hosted
agent records its own for the same request. The message the readiness probe raises no longer travels
through a shared constant, since each check says its own thing.

* Address the review comments left open on the merged PR

Five points raised on #7525 were marked resolved without a code change, and the code they pointed at
was still there.

A hosted workflow session is now recognised by its full type name, so a session of the same short
name from another namespace is not mistaken for one. The test double moves into the namespace it
stands in for, otherwise it would no longer exercise the check.

The per-run chat history provider is handed over on AgentRunOptions.AdditionalProperties, which
ChatClientAgent copies onto the chat options with precedence, rather than being written onto the
chat options here.

The test that pins down who supplies the history said the agent's own provider is used, while it
asserts the opposite, so it is renamed after what it checks.

Reading a response back in the hosted integration tests no longer swallows every failure: only
"not stored" and "not readable through this endpoint" are, so an expired token or a server fault
cannot be mistaken for an absent response and pass the test.

Also fills in the readiness message for the case where storing is explicitly allowed.

* Address the review on #7572

Four findings, all real, all in code this branch introduced.

A container that allows its own service to keep the conversation was still being handed the platform
history on every turn. That service replays the earlier turns itself, so the model was getting each
of them twice, which is the very thing this work exists to prevent. The history now goes in only
while nothing else holds it: the first turn of such a conversation still gets it, and the service
takes over from there.

A turn that fails for storing downstream was announcing itself as completed first and only then
failing, leaving the caller with two different answers for the same turn. The completed event is now
held back until the run is wound up and the session can be read, because the id of any conversation
the agent's service kept only lands there at the very end.

The readiness probe replaced the chat client but left the agent's chat history provider running, so
a provider backed by a database was reading and writing on every probe, and adding the probe's empty
turn to a real conversation. It is stood down for that run now.

The probe also treated any cancellation as the health check's own, so a timeout inside an agent could
fail readiness. Only a cancellation of the health check's token is left to propagate.

Fixing the completed event turned up a latent problem: the terminal event types are named the same in
two namespaces this file pulls in, and the short name binds to the ones the response stream never
produces, so vt is ResponseCompletedEvent was quietly always false. The three terminal types are
now named explicitly.

* Let the chat history provider carry the conversation

The handler used to read the hosting service's record of the conversation and prepend it to the
input of every run, then work out who should not get it: a resumed workflow by the name of its
session type, and a container whose own service already holds the conversation. Two exceptions, a
type name matched as a string, and a shape where the same turns could arrive from two directions.

An agent that reads its history through a provider is now given one, seeded with that record, for
the length of the run. The turns arrive the way the agent expects them rather than as fresh input,
so nothing is stored back as if it had just been said, and the provider is dropped when the run
ends. Only the new input is passed to the run now.

Everything else supplies its own history and is left alone: an agent built with a provider keeps
using it, an agent whose service keeps the conversation reads it from there, and an agent that is
not a ChatClientAgent, a hosted workflow for instance, carries the conversation in its own session
state and wants only the new input. The workflow session type name check is gone with it.

The session is saved on every turn again. It was being withheld when the agent's own service had
kept the turn, which is a decision about that service, not about the session; nothing this handler
adds for a turn reaches the session anyway.

* Fail a turn to skip its session, and name the store check after what it detects

The session was being withheld from the store on a condition about the agent's own service rather
than about the turn, and guarded by an emptiness check on a key that is never empty. A turn that is
being failed now says so, and only that skips the save. A turn that ends incomplete, waiting on
OAuth consent or interrupted by a shutdown, is not a failure: the caller comes back for it and needs
the state built up so far, the tool approval ids among it.

The session key is resolved once as a value that always exists, so both the load and the save use it
without asking again whether it is there.

CheckNotAllowedStoreUsage and notAllowedStoreUsageDetected now read as what they are: a check for an
agent storing when it should not, and the flag saying it was seen.

* Read a hosted response through the agent client, and only forgive a 404

Reading a response back tried the project-level client first and then the per-agent one, swallowing
403 as well as 404 to get past the first. The project-level client cannot see a hosted agent's
responses at all, so that attempt only ever produced the 403 the catch then had to forgive, and any
other 403, an authorization failure for instance, was read as "nothing is stored" and passed the
test.

Only the per-agent client is used now, and only a 404 counts as not stored. Verified against the
service: a well-formed id it has no response for answers 404 invalid_request_error "Response '...'
not found", the same id through the project-level client answers 403 session_not_accessible, and a
malformed id answers 400. Everything but the 404 now surfaces.

* Move the store setting next to the code that reads and writes it

The two halves of the stored output concern lived in a shared helper: one that installs the factory
turning storage off, and one that reads back what a request would have asked for. Each had exactly
one caller, so the helper only added a hop. They now sit in the converter that builds the request and
in the probe client that inspects it, and the helper keeps just the error the handler throws.

The test double standing in for a hosted workflow session is also gone. It was declared inside the
Workflows namespace because the handler used to recognise a resumed workflow by the full name of its
session type; that comparison no longer exists, so the double only needs to not be a ChatClientAgent.

* Say that the stored output setting could not be determined, which is the case being logged
2026-08-11 16:22:26 +00:00
Peter Ibekwe 8a0731ad92 .NET: Prevent telemetry serialization failures from failing workflows (#7612)
* Prevent telemetry serialization failures from failing workflows

* Address PR comments
2026-08-11 15:16:40 +00:00
Peter Ibekwe 6fff2c9b1f Fix misleading workflow protocol attribute diagnostics (#7609) 2026-08-11 15:16:21 +00:00
Saurish 0d75365331 .NET: Add Cosmos NoSQL vector memory sample (#7552)
* .NET: Add Cosmos NoSQL vector memory sample

* Address Cosmos memory sample review feedback

* Fix Cosmos NoSQL memory sample build

---------

Co-authored-by: nos-redacted <nosxredacted@gmail.com>
2026-08-11 10:33:06 +00:00
Peter Ibekwe db979b616a Python: Improve Json parsing for declarative workflow (#7550)
* Json parsing improvement

* Fix PR comments

* Address PR comments.
2026-08-11 05:09:28 +00:00
Tao Chen d0a4165f17 [BREAKING] Python: Migrate FHA to responses==2.0.0b1 and add Foundry state store (#7533)
* Migrate FHA to responses==2.0.0b1 and add Foundry state store

* Fix session id error

* Fix tests

* Improve tests

* Fix copilot comments

* Address comments

* Revert sample changes

* Address comments

* Add ContextScopedStoreProvider

* Fix type check

* Fix type check

* Export ContextScopedStoreProvider
2026-08-10 05:51:59 +00:00
dependabot[bot] 4357ff5742 Bump postcss (#7529)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.22 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.22...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 02:47:05 +00:00
dependabot[bot] 221f4b6df1 Bump postcss from 8.5.15 to 8.5.25 in /python/packages/devui/frontend (#7493)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 02:46:59 +00:00
dependabot[bot] a9f7b2b788 Bump pyrefly from 1.1.1 to 1.2.0 in /python (#7541)
Bumps [pyrefly](https://github.com/facebook/pyrefly) from 1.1.1 to 1.2.0.
- [Release notes](https://github.com/facebook/pyrefly/releases)
- [Commits](https://github.com/facebook/pyrefly/compare/1.1.1...1.2.0)

---
updated-dependencies:
- dependency-name: pyrefly
  dependency-version: 1.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 02:44:59 +00:00
dependabot[bot] adcc3de654 Bump js-yaml from 4.3.0 to 4.3.1 in /python/packages/devui/frontend (#7554)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 02:42:11 +00:00
dependabot[bot] 034f5fa119 Bump zuban from 0.9.0 to 0.9.1 in /python (#7545)
Bumps [zuban](https://github.com/zubanls/zubanls-python) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/zubanls/zubanls-python/releases)
- [Commits](https://github.com/zubanls/zubanls-python/compare/v0.9.0...v0.9.1)

---
updated-dependencies:
- dependency-name: zuban
  dependency-version: 0.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 02:41:55 +00:00
Evan Mattson 48e547506b Python: Make encrypted reasoning opt-in for Foundry chat (#7536)
* Python: Make Foundry encrypted reasoning opt-in

* Python: Opt hosted replay test into encrypted reasoning
2026-08-10 02:36:48 +00:00
Peter Ibekwe 5eb3eb745e Improve string parsing in declarative workflows (#7535) 2026-08-07 18:07:56 +00:00
SergeyMenshykh c987529df3 .NET: [BREAKING] Rename to AgentIsolationKeyProvider (#7567)
* Update store isolation documentation

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

Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Rename store isolation key provider

Rename the shared session isolation abstraction to reflect its use for both session and task stores.

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

Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Rename to AgentIsolationKeyProvider per review feedback

Drops the `Store` qualifier and keeps an `Agent` prefix so the type is not
confused with generic isolation-key abstractions from other libraries, while
leaving room for future non-store isolation (memory, retrieval).

- StoreIsolationKeyProvider -> AgentIsolationKeyProvider
- ClaimsIdentityStoreIsolationKeyProvider(+Options) -> ClaimsIdentityAgentIsolationKeyProvider(+Options)
- GetStoreIsolationKeyAsync -> GetIsolationKeyAsync
- UseClaimsBasedStoreIsolation -> UseClaimsBasedAgentIsolation

XML docs now state that the `Agent` prefix identifies the hosting API domain and
does not mean agent instances are isolated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Update hosting spec for AgentIsolationKeyProvider rename

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292
2026-08-07 14:59:07 +00:00
westey aaaa56bc60 .NET: Store executable function calls bypassed by declaration-only tool calls (#7388)
* Allow storing executable functions when mixed with non-executable

* Address PR review feedback on executable function bypassing

- Guard enumerator acquisition so pending bypassed calls are restored when
  the inner client throws synchronously, before the first MoveNextAsync.
- Always surface buffered streaming updates, even when stripping empties
  them, so metadata such as ConversationId and ResponseId is not discarded.
- Document that the decorator must sit below ApprovalResponseBindingChatClient,
  which drops approval responses that have no recorded request.

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

* Address PR comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 13:52:31 +00:00
Roger Barreto 18ceb182b1 .NET: Give a hosted agent a single source of conversation history (#7525)
* Read hosted chat history through a provider instead of the request input

The handler used to fetch the platform conversation history and prepend it to the
input of every turn. For a ChatClientAgent that runs in parallel with its own chat
history provider, so the conversation had two sources at once. It also had a hidden
cost: platform items carry no chat-history source marker, so the agent's provider
stored them again as if this turn had written them, leaving a second copy of the
conversation inside the persisted session that then diverges from the platform.

Make the chat history provider the single source for a ChatClientAgent:

- Add FoundryChatHistoryProvider, which reads the conversation through
  ResponseContext.GetHistoryAsync (it already resolves previous_response_id and the
  conversation the request belongs to) and stores nothing, because the platform
  persists the response items itself. An instance is created per request because it
  holds that request's context, and it is passed as a run-scoped override so the host
  does not have to mutate the agent.
- Register it only when the agent was created without a chat history provider. When
  one was supplied at construction, that provider owns the conversation and the
  platform history is not used at all.
- Stop adding the platform history to the input for a ChatClientAgent, since the
  provider now delivers it.

A workflow hosted as an agent is not a ChatClientAgent and has no provider pipeline,
so it keeps receiving the platform history from the handler exactly as before.

* Add regression tests for the duplicated hosted chat history

Cover the three symptoms the previous handler produced, each verified to fail when
the handler is reverted to fetching the platform history into the turn input:

- the conversation the service already keeps was copied into the persisted agent
  session by the default in-memory history provider;
- a custom history provider was asked to write that same conversation into its own
  database, because platform items carry no chat-history source marker and so look
  like content this turn produced;
- an agent with its own provider received both that provider's history and the
  platform's in a single request.

Also state precisely, in the provider's remarks, why nothing is written back: for a
stored request the response orchestrator hands the finished response to its responses
provider, which persists the input and output items that a later turn then reads back
through GetHistoryAsync; for a non-stored request nothing is persisted and nothing is
readable, so the request is self-contained either way.

* Keep unstored turns in the session so mixed conversations stay whole

A conversation can mix turns the service stores with turns it does not. History is
resolved from previous_response_id or the conversation regardless of the current
request's store flag, so an unstored turn still reads the stored ones back, but the
service records nothing for it and a later turn would never see it again.

Reading the platform history through FoundryChatHistoryProvider alone lost those
turns: from the second turn onwards the handler treats the session as a resume and
stops feeding history in, and the provider kept nothing of its own, so an unstored
turn simply vanished from the conversation. A regression test drives three turns of
one conversation, the first stored and the rest not, and without this change the
model receives only [second question, ok, third question]: the stored opening turn
is gone.

Give the provider both halves instead of choosing one:

- reading returns what the service serves, followed by the turns kept in the session,
  which are by definition later than anything the service recorded;
- writing keeps a turn only when the service was not asked to store it, so a stored
  turn is never duplicated and an unstored one is never lost.

The turns are held in the agent session under the provider's own state key, so they
travel with the session the host already persists.

* Refuse a stored turn once a conversation holds unstored ones

A conversation can move between stored and unstored turns, and the unstored ones live
only in the agent session. Going back to a stored turn after that would have the
service record it on top of turns the service never saw, so anyone reading the
conversation back from the service would find an answer with no question. Refuse it
before the model is called instead of writing that gap.

Cover the whole shape with a walkthrough of nine turns over one conversation and three
provider instances, each with its own session:

- an instance that never took an unstored turn starts from the turn the service last
  saved, and does not see another instance's unstored turns;
- an instance that did keeps reading the saved turns and adds its own on top;
- asking such an instance for a stored turn is refused, twice, while unstored turns
  keep working;
- a turn stored from one instance does not appear for another, because it sits on a
  different branch of the conversation and so is not among the turns leading to what
  that other instance last saved.

* Say plainly that kept turns belong to the session

The turns the service was not asked to store are written into the agent session's state
bag under this provider's own state key, and a new provider is built for every request,
so nothing is held on the provider object itself. The walkthrough named its three
threads after provider instances, which read as if the object carried the memory.

Name them after the sessions they are, and add a test that pins the behaviour down: a
turn kept through one provider object is read back by a different one given the same
session, and is absent for one given another session.

* Show which half of the conversation each provider decides

The session decides what is kept, but the provider still decides two things: which
service-side conversation is read, because it holds the request's response context, and
whether the turn is kept at all, because it holds the request's store flag.

Add two tests that separate those from the session:

- two providers reading one session, each built for a request of a different
  conversation, return the same kept turn behind different served turns;
- two providers writing to one session, one for a stored request and one for an
  unstored one, leave only the unstored turn behind.

* Say why a hosted workflow keeps taking history from the handler

The comment stated that a workflow hosted as an agent has no provider pipeline
without saying what that means. It derives from AIAgent directly, so it never calls
a ChatHistoryProvider and does not read the run options' additional properties: the
provider could not reach it even if it were registered.

* Ask the session store whether a turn is a resume

The handler decided that a turn was resuming an existing conversation by looking for
state on the session. That reading broke once the handler itself started writing to the
session before the check: it records the caller's identity there, so a session created
moments earlier already carried state and the very first turn of a conversation looked
like a resume. Its history was then never fetched, and the agent answered knowing
nothing of a conversation the service was already holding. It only showed up when
hosted, because running locally there is no identity to record.

Let the store answer the question instead. GetSessionAsync now returns null when nothing
is stored rather than quietly handing back a new session, so a non-null result means a
prior turn established this session and nothing else has to be inferred. Callers that
just want a usable session can use the new GetOrCreateSessionAsync, which is written in
terms of GetSessionAsync so a store overriding one gets the other for free.

Both store implementations and their tests follow the plain-lookup contract: a miss
creates nothing, deserializes nothing, and touches no directory.

* Drop the experimental marker from an internal type

FoundryChatHistoryProvider is internal, so the attribute reached no caller: the marker
exists to warn people consuming the public surface. It also does not follow from the base
type, which does not carry one, and most internal types in this package have none either.
Removing it leaves two usings behind, so they go as well.

* Stand down the agent's second-manager guard for the host's own provider

An agent refuses a second history manager once the model reports a conversation id of its
own, which happens as soon as the container lets the model keep the conversation. The
guard is meant for an application that configured a provider by hand and would otherwise
end up with two of them. Here the host is the one supplying the provider, deliberately and
for every turn, so the guard was rejecting the arrangement it is hosting: the first turn
failed while streaming, and every later one failed before reaching the model at all.

Turn the three conflict settings off on the agent the host is serving, and let the
provider decide what reaches the model. A test drives two turns of one conversation
against a model that reports a conversation id and asserts both complete.

* Pass a caller's request not to store on to the chat client

A request asking the hosting service not to store the response was honoured there and nowhere else, so the service behind the agent's own chat client kept recording the conversation and reporting an id for it. A caller opting out of storage still ended up with a stored conversation, and the container went on continuing it.

Only that direction travels. Carrying store=true across would either force storage on a container whose author turned it off on purpose or change nothing, since storing is already the default.

* Hand the conversation to the agent's own provider instead of a host one

The host no longer supplies a chat history provider of its own. It writes the turns the service holds into the provider the agent already created for itself, and only when that is the stock in-memory one, so an agent given a provider keeps sole control of its storage and the model receives the conversation once.

A conversation the caller stops asking the service to store moves into the session state and stays there. The session's conversation id no longer names anything the service records and cannot be cleared, so the session is cloned without it on that single turn. Asking for a stored turn afterwards is refused: the service would record a turn whose predecessors it does not hold.

An agent that does not read history through a provider, a hosted workflow for example, is still given its prior turns as input, now marked as chat history so no provider along the way stores them as new.

* Run the agent's own request factory instead of replacing it

ChatClientAgent chains a request's raw representation factory with the agent's by taking the agent's only when the request's returns null. The factory added for an unstored turn always answers, so anything the container configured on the agent's ChatOptions was silently dropped for that turn.

The agent's factory is now invoked first and its result is what carries the setting. A result that is not a CreateResponseOptions belongs to some other chat client, which has no notion of storing a response, so it is handed back untouched.

* Cover a stored conversation that stops being stored and asks again

The refusal was only tested on a conversation the service never stored. Reaching it from a stored one goes through the turn that rebuilds the session without its conversation id, so the mark saying the conversation left the service has to survive that rebuild to be found on the next turn.

* Leave the conversation to the AgentServer storage provider alone

The AgentServer SDK records a hosted turn through its own storage provider, around the handler, and serves the conversation back through ResponseContext.GetHistoryAsync. Anything the container stores of its own is a second conversation that storage provider never sees and no one reconciles.

The handler now takes that history as the single source and hands it to the agent as input alongside this turn's messages. The agent's own provider is replaced for the run by one holding its messages in a field, so a run that calls tools still has what its earlier calls produced while nothing survives the request. The service behind the agent's chat client is asked not to store on every turn, whatever the caller asked of the hosting service.

A session that still carries a conversation id means that service is recording a second conversation regardless, so the turn is refused with a 400 rather than run against something nobody can reconcile.

* Narrow the history skip to a resumed workflow

Withholding the conversation from every agent that is not a ChatClientAgent assumed they all carry it in their own session. A hand-written one that keeps nothing would answer with no history from its second turn on, so the check is now on the session type a workflow runs with, which is what actually accumulates the turns.

The conversation and previous response id tests went with it: the session key falls back to the partition of a freshly minted response id, which never has a session saved for it, so a loaded session already implies one of the two was sent.

Also asks a Chat Completions client not to store, since the setting carries the same name on both OpenAI request shapes.

* Add a live test that a hosted turn is not stored twice

The AgentServer SDK's storage provider records every hosted turn around the handler, and
that record is the conversation the caller reads. The agent's own run inside the container
talks to its own service, and when that service is asked to keep the turn it writes a
second copy of the same exchange, on a trail of its own that nobody reads and nobody
reconciles. The caller's conversation looks clean, so the second copy goes unnoticed.

The new downstream-store scenario runs an ordinary Foundry ChatClientAgent, like the first
hosted agent sample, wrapped so that after the run it appends DOWNSTREAM_ID=<id> to the
reply, carrying whatever its own run left behind. The tests then go looking for that id on
the service: finding it means a second copy exists.

Verified live against a Foundry project. On main both tests fail, reporting a readable id
such as resp_0940e276..., and here the container reports DOWNSTREAM_ID=none and both pass.

* Let the session carry the conversation in the downstream store test

The run options were setting the conversation on every call, which the session already does.
The single turn test now binds the session to the conversation up front, and the multi turn
test starts from the agent's own default session and reads back what the hosted agent kept
for the caller off ChatClientAgentSession once the first turn returns.

Re-verified live: still fails on main, reporting a readable id such as resp_0c07a5e4..., and
still passes here.
2026-08-07 10:02:23 +00:00
westey ec32e86646 .NET: Aggregate usage across looping agents and chat clients (#7539)
* Ensure usage is merged for all looping components

* Add max tool approval loop fixes

* Fix net472 build break in usage aggregation tests

DateTimeOffset.UnixEpoch is not available on .NET Framework 4.7.2, so the
WithAggregatedUsage copy tests failed to compile for that target framework.
Use an explicit DateTimeOffset instead; the specific instant is irrelevant,
the value only needs to be non-default so the copy assertion is meaningful.

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

* Address PR comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:00:27 +00:00
SergeyMenshykh 94bbfb2ac8 .NET: Harden file skill discovery (#7540)
* .NET: Harden file skill discovery

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

Copilot-Session: 8ff072fa-42d6-44b0-b226-2182cfc7639c

* .NET: Handle inaccessible skill directories

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

Copilot-Session: 8ff072fa-42d6-44b0-b226-2182cfc7639c

---------

Copilot-Session: 8ff072fa-42d6-44b0-b226-2182cfc7639c
2026-08-07 09:53:56 +00:00
Giles Odigwe 4b1afd9052 Python: surface Gemini thought summaries as reasoning content (#7488)
Gemini thought-summary parts (part.thought=True) were dropped in _parse_parts, so reasoning never reached ChatResponse.contents. Emit them as text_reasoning content instead, matching OpenAIResponsesClient. Round-trip is safe: _convert_message_contents never re-emits reasoning text as a Part.

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

Copilot-Session: b8aa906e-1408-40c1-9a45-6deb40dc36f8
2026-08-07 03:08:25 +00:00
Giles Odigwe 45c515b8a7 Python: fix CopilotStudioAgent LineTooLong on large activities (#7417)
* Python: fix CopilotStudioAgent LineTooLong on large activities

Bump microsoft-agents-copilotstudio-client to >=1.2.0,<2 and forward a configurable read_bufsize (default 1 MiB) to the underlying aiohttp ClientSession via ConnectionSettings.client_session_settings. Copilot Studio streams each activity as a single SSE data line, so activities larger than aiohttp's 512 KB per-line limit previously raised aiohttp.http_exceptions.LineTooLong. Adds a client_session_settings parameter to CopilotStudioAgent and unit tests covering the default, override, and partial-settings cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2766dc09-ab5f-4adc-8627-98361d7ccef0

* Python: apply read_bufsize default to supplied CopilotStudio settings

Address review feedback on the LineTooLong fix: when a user supplies their own ConnectionSettings but no client, inject the read_bufsize default so activities larger than aiohttp's 512 KB per-line limit still stream. Document configuring read_bufsize on the explicit pre-built-client path in the package and sample READMEs and the explicit-settings sample. Add unit tests covering the supplied-settings path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2766dc09-ab5f-4adc-8627-98361d7ccef0

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2766dc09-ab5f-4adc-8627-98361d7ccef0
2026-08-07 03:08:06 +00:00
Giles Odigwe b2a2fcbd87 Python: Add response/request customization hooks to OpenAIChatCompletionClient (#7028)
* Python: Fix reasoning content parsing in OpenAIChatCompletionClient

Fix two issues with reasoning content handling in the Chat Completions
client:

1. (#6979) reasoning_details plaintext buried as encrypted data:
   The client dumped the entire reasoning_details array into
   Content.protected_data without setting Content.text, causing AG-UI
   to emit ReasoningEncryptedValueEvent instead of visible
   ReasoningMessageContentEvent for plaintext reasoning providers
   (e.g. OpenRouter). Now extracts readable text from reasoning_details
   entries into Content.text while preserving protected_data for
   round-trip fidelity.

2. (#6978) Mistral list content causes crash:
   Mistral reasoning models return content as a list of typed chunks
   ([{"type": "thinking", ...}, {"type": "text", ...}]) instead of a
   plain string. _parse_text_from_openai assumed content was always a
   string, causing a Pydantic ValidationError downstream. Now detects
   list content and parses thinking chunks as Content.from_text_reasoning
   and text chunks as Content.from_text.

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

* Fix pyright strict-mode type errors and handle content-as-string shape

- Use cast() for proper type narrowing in _extract_reasoning_text and
  _parse_chunked_content to satisfy pyright strict mode
- Handle {"content": "..."} string shape in _extract_reasoning_text
  (addresses review comment about missing format coverage)

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

* Fix mypy errors: cast list content to Any in tests

model_construct bypasses Pydantic runtime validation but mypy still
checks declared types. Use cast(Any, ...) for the list content args.

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

* Address review comments: summary field, reasoning field, and round-trip

- Add 'summary' field extraction in _extract_reasoning_text for
  reasoning.summary entries from OpenRouter
- Handle message.reasoning and message.reasoning_content top-level
  fields (plaintext reasoning without reasoning_details) in both
  streaming and non-streaming paths
- reasoning_details takes priority when both fields are present
- Preserve original Mistral chunk list in additional_properties
  ('_source_content_list') so _prepare_message_for_openai can
  reconstruct the structured list content for multi-turn reasoning
- Add 5 new tests covering all new behaviors

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

* Fix ruff used-dummy-variable: rename _skip_structured_siblings

Remove leading underscore from _skip_structured_siblings variable since
it is accessed (not a dummy variable). Ruff's used-dummy-variable rule
flags variables with leading underscores that are read.

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

* Fix missing newline at end of test file

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

* Address review: type-agnostic chunk round-trip and reasoning field echo-back

- Honor the _source_content_list marker regardless of the first emitted
  content's type by handling it before the type match, so a chunk list
  beginning with a text chunk still round-trips as one structured message
  (addresses github-actions review comment on results[0]).
- Tag every chunked-content item with a shared _structured_content_group
  id and skip only exact group siblings during serialization, instead of
  suppressing all later text/reasoning content.
- Record provenance of top-level reasoning/reasoning_content fields in
  _reasoning_source_field and echo the value back under the same key on
  the next request, which providers such as vLLM require (addresses
  Kimahriman review comment). Replaces the prior behavior that replayed
  surfaced reasoning as visible answer text.
- Factor the duplicated reasoning parsing into _parse_reasoning_content.
- Add tests for provenance capture, reasoning/reasoning_content round-trip,
  reasoning-only messages, text-first chunk round-trip, and unrelated
  sibling preservation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5

* Replace provider-specific reasoning logic with configurable parse/prepare hooks

Following review feedback (#7028), keep OpenAIChatCompletionClient free of
provider-specific quirks for 'almost OpenAI-compatible' endpoints. Instead of
branching in core for OpenRouter/vLLM/Mistral, expose two optional callables so
callers adapt the client themselves:

- response_parser (OpenAIChatResponseContentsParser): post-processes the Content
  list parsed from each response choice/streaming delta, to surface non-standard
  fields (e.g. reasoning/reasoning_content/reasoning_details) for display.
- message_preparer (OpenAIChatMessagePreparer): post-processes the outgoing request
  message dicts built from each framework Message, to echo provider-specific fields
  back on later turns (e.g. vLLM reasoning) for multi-turn continuity.

Both default to None (no-op; byte-identical stock OpenAI behavior). This reverts the
provider-specific reasoning/chunked-content parsing and round-trip markers previously
added to core; Mistral chunked content is now handled by agent-framework-mistral.

- Add the two callables to RawOpenAIChatCompletionClient / OpenAIChatCompletionClient
  constructors and invoke them at the parse and prepare seams.
- Export the type aliases from the package and the core lazy openai namespace (+ .pyi).
- Replace the removed-behavior tests with tests for the two hooks.
- Document the hooks in packages/openai/AGENTS.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5

* Skip non-string content in default text parsing

Structured list content (e.g. Mistral reasoning models returning content as a
list of chunks) was wrapped verbatim into a text Content, producing a malformed
Content whose text is a list that crashes downstream (issue #6978). Default text
parsing now skips non-string content so a configured response_parser receives a
clean slate to expand it. Applies to both streaming and non-streaming paths.

Add tests for the skip and for a response_parser expanding chunked content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5

* Address review: hook signature, per-role preparer, robust round-trip

- response_parser now receives the already-selected ChatCompletionMessage /
  ChoiceDelta instead of Choice | ChunkChoice, so callers no longer duplicate the
  streaming dispatch (removes the Any/hasattr pattern from tests). The client owns
  the dispatch; parsers read provider fields directly.
- message_preparer now runs once per Message for every role: the build logic moved
  to _build_openai_messages and the hook is applied at a single exit point in
  _prepare_message_for_openai, so system/developer messages no longer bypass it.
- Round-trip example/test now correlates surfaced reasoning via an
  additional_properties marker on message.contents with bounded, order-aware,
  one-to-one dict removal, instead of fragile request-string matching. Adds a test
  proving an answer whose text equals the reasoning text is no longer dropped.
- Update packages/openai/AGENTS.md for the new parser signature and guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5
2026-08-07 02:51:06 +00:00
MohammadHaroonAbuomar 7302d0bf23 Python: agent-hooks interception contract as a first-class experimental core feature (#7515)
* feat(python): add agent-hooks middleware as experimental core feature

Implement the AGENT-HOOKS-0.1 interception contract as a first-class
experimental feature in agent_framework core.

- Single public factory agent_hooks_middleware() returning a private
  agent/chat/function middleware trio (one object per middleware
  category); partial or stacked installs fail closed with loud errors.
- All eight interception points: input/output at the agent seam,
  pre/post_model_call at the chat seam, pre/post_tool_call at the
  function seam, agent_startup/agent_shutdown bracketing each run.
- Fail-closed enforcement throughout: transforms write back into the
  native contexts (messages, arguments, results) or raise; content is
  preserved as Content objects; MiddlewareTermination short-circuits
  are guarded at every seam; enforcement-layer failures halt the run;
  interceptor crashes surface as host_error denies.
- Streaming is fully buffered per spec buffered_output semantics: no
  update egresses before the post_model_call/output verdicts; a deny
  at pull time releases zero updates; run state stays active across
  lazy pulls with cleanup on every exit path.
- Session scoping: per-run by default (startup/shutdown bracket each
  run) or host-owned via emitter/builder parameters for one session
  spanning multiple runs.
- agent-hooks-sdk is an opt-in agent-hooks extra (not in all),
  lazy-imported per the _mcp.py pattern; core imports cleanly without
  it and the factory raises a clear ModuleNotFoundError.
- ExperimentalFeature.AGENT_HOOKS + @experimental decorator, lazy root
  export, typing surface, PACKAGE_STATUS.md entry.
- 55 tests built on real Agent/mock-client flows covering deny-before-
  execution, transform write-back, rich-content preservation, complete
  streaming ordering, error cleanup, concurrency isolation, nested
  agents, and importability without the optional SDK.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* style(python): unquote ResponseStream annotation per pyupgrade

The pre-commit pyupgrade hook rewrites the quoted forward reference;
ResponseStream is imported at runtime in this module, so the quotes
were unnecessary.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* refactor(python): address agent-hooks review feedback

Reworks the agent-hooks feature per PR review:

- Verdicts now precede durability: a run-scoped persistence gate
  (_sessions.py) defers per-service-call history persistence and
  after-run provider work until the covering post_model_call/output
  verdict permits; denied content never persists, transforms persist
  post-write-back. Unhooked runs are unchanged (verified against an
  instrumented baseline).
- ResponseStream.buffered_and_gated: a buffered-gate combinator that
  applies the run's pending stream hooks before the gate, then seals
  the stream, so no middleware can rewrite egress after the output
  verdict. Replaces the hand-rolled replay iterator.
- MiddlewareBundle (public, _middleware.py): the factory returns an
  indivisible bundle categorize_middleware splits, making partial
  installs impossible by construction; members are validated at
  construction. Bare (non-sequence) middleware at agent construction
  is now normalized instead of silently dropped, and unrecognized
  middleware logs a warning instead of vanishing.
- Factory split and rename: create_agent_hooks_middleware (per-run
  sessions) and create_agent_hooks_middleware_from_emitter
  (host-owned); the sentinel parameter-diffing is gone.
- Wire conversions live in per-point codec classes owning to_wire and
  write_back. Fixes in that code: tool-call name transforms apply or
  raise; non-object args transforms raise; argument write-back merges
  only changed keys (original values, including bytes, preserved by
  identity); message-list write-back matches by identity, not index.
- function_approval_request objects on the normal return path pass
  through un-emitted, preserving the human approval pause.
- Hosted (service-executed) tool calls surface in the post_model_call
  content projection; the tool-seam limitation is documented.
- Import probe covers the full SDK surface and re-raises as
  missing-extra only for the agent_hooks module; module logger added;
  _json_safe replaced by make_json_safe (which gained bytes support);
  tools_registered uses normalize_tools; dependency-pyright analyzes
  the module again via the test dependency-group.
- Tests: 75 in the feature suite (persistence gating, stream-hook
  sealing, approval passthrough, codec units, bundle validation,
  bare-bundle installs), full core suite green.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* refactor(python): second review round for agent-hooks

Addresses the second review round on the agent-hooks feature:

- Nested-run persistence ownership: RawAgent.run stamps a run identity
  over the run's dynamic extent (including streaming pulls and result
  hooks); the persistence gate binds to its owning run via an
  offer/adopt handshake keyed to the agent instance and accepts only
  its owner's persists — nested runs persist inline regardless of how
  they were started (tool calls, middleware, custom run loops). The
  tool-seam suspension remains for custom-loop sub-agents invoked as
  tools; the one residual case (custom loop nested in a custom loop
  off the tool path) is fail-closed and documented. Fixes a latent
  pre-existing re-deferral: flush() now drains with the gate context
  suspended, so a nested hooked run's permitted after-run persistence
  no longer re-defers into an enclosing gate.
- as_tool stream_callback consumes the released (verdicted) stream;
  observers cannot see denied or pre-transform content. Both
  directions are regression-tested.
- categorize_middleware gained supported_categories: a bundle member
  landing in a category a call site cannot install raises; bare
  middleware warns like _add_middleware. Wired at the chat-client
  sites and the provider seam.
- ResponseStream.buffered_and_gated owns the re-derivation rule via a
  rederive callable (gates cannot choose released updates) and is
  marked experimental.
- Wire codecs compare with bool-aware equality (Python == equates
  1 == True, which made bool/number transforms look untouched and get
  dropped) and _ToolResultCodec.write_back owns the untouched-wire
  rule via the before value.
- middleware parameters accept a bare middleware or bundle everywhere
  the runtime does (constructors, run overloads, as_agent, telemetry
  and harness layers, foundry); the bare-source rule has a single
  owner in categorize_middleware; bare middleware assigned to the
  attribute now executes (documented behavior change).
- MiddlewareBundle is experimental and validates members; approval
  passthrough, typing-check fixes (ty ignores mypy-coded ignore
  comments), logging, and documentation updates per review.

Test count: 85 feature tests plus 12 new this round across sessions,
middleware, agents; full core suite green; typing checked under
mypy, pyrefly, ty, zuban, and pyright.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* docs(python): drop previous-behavior notes from middleware docstrings

Per review: docstrings describe current behavior only. The
bare-middleware behavior change stays recorded in the PR description
and commit history.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* fix(python): gate ownership survives retrying middleware

A retry or fallback middleware issuing a second call_next() gave the
new attempt a fresh run identity that the persistence gate's
first-bind-wins ownership rejected, so the retried attempt's history
persisted inline before the output verdict — a denied response became
durable again. The gate now accumulates every identity adopted
through its own offer ticket: all attempts' persistence stays behind
the one final verdict (deny drops all of it, allow flushes all of
it). Accumulation over rebind-replace is deliberate: rebinding would
flip an earlier attempt's still-running background work from deferred
to inline, which is the fail-open direction. A foreign agent still
cannot bind: tickets are minted only by the covered pipeline's final
handler and adoption is instance-keyed.

Also consolidates the bare-middleware-source rule into a single
_as_middleware_list owner used by every interpretation site (the
harness merge, BaseAgent.__init__, categorize_middleware, both
client-kwargs merges, get_response, SessionContext.extend_middleware),
including the str/bytes exclusion the stray copies missed. The
constructor now stores a copy of the caller's sequence; assign to the
middleware attribute for post-construction changes.

Retry regression tests cover denied and allowed retried runs in both
stream modes and fail with first-bind-wins restored.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* fix(python): streaming seam runs pipeline descent inside the gate

The streaming agent seam ran call_next() outside the persistence
gate (only _consume entered it later), so a retry middleware that
drained a successful attempt with get_final_response() and discarded
it persisted that attempt's exchange before any verdict existed; a
later deny dropped only the retry attempt's deferred work. The
descent is now wrapped in the gate exactly like the non-streaming
seam: attempt identities adopted during descent are accepted owners,
so in-pipeline draining defers, deny drops every attempt, and a
middleware that raises after draining strands the pending persists
unexecuted. The bind_owner docstring now states the actual soundness
invariant covering both bind sites: every bind comes from a run
inside the covered pipeline.

New tests cover drained-and-discarded attempts (deny and allow, both
stream modes) and a sub-agent tool inside a drained attempt; the
streaming deny variant fails with the gate wrap reverted.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

* fix(python): flush deferred persistence on streaming no-result termination

With the pipeline descent now running inside the persistence gate, a
middleware that drains a successful attempt and then terminates
without a result left that attempt's deferred persistence stranded:
the streaming no-result termination path raised before any flush, so
history of exchanges that really happened and passed their own
verdicts quietly vanished (streaming only; non-streaming already
flushes before its re-raise). The path now flushes before re-raising
the termination, with a state.halted guard first so an enforcement
failure during the drained attempt still strands pending fail-closed
and surfaces the halt, mirroring the non-streaming ordering exactly.

The regression test covers both seams; the streaming variant fails
without the fix.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

---------

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
2026-08-07 00:25:09 +00:00
westey 422160eabe Python: Add windows junction detection for skills (#7507)
* Add windows junction detection for skills

* Address PR comment
2026-08-06 09:08:37 +00:00
westey 5a1d96df67 Python: Separate mem0 storage and search scopes (#7531)
* Separate mem0 storage and search scopes

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-06 08:32:36 +00:00
Atharva Vichare 74a144085a .NET: Bound the tool-approval auto-approval loop (#7472) (#7474)
* .NET: Bound the tool-approval auto-approval loop (#7472)

`ToolApprovalAgent` re-invoked the inner agent from two unbounded `while (true)`
loops whenever every surfaced approval request was auto-approved. Each pass is a
fresh `InnerAgent.RunAsync` / `RunStreamingAsync` call, so a per-request cap such
as `FunctionInvokingChatClient.MaximumIterationsPerRequest` restarts every time
and cannot bound the chain. Under `AllToolsAutoApprovalRule` a model that keeps
requesting an auto-approved tool therefore drives billable model calls
indefinitely; the reporter measured 100M+ tokens over three days.

Adds `ToolApprovalAgentOptions.MaxAutoApprovalIterations` (default
`ToolApprovalAgent.DefaultMaxAutoApprovalIterations`, 10) and bounds both loops.
Naming, default and `Throw.IfLessThan` validation follow the existing
`LoopAgent.DefaultMaxIterations` / `LoopAgentOptions.MaxIterations` convention in
this assembly.

On reaching the cap the agent takes one final inner turn without auto-approving
again, so a remaining approval request is surfaced to the caller to decide.
Returning early instead would hand back an empty response, because
`ProcessAndQueueOutboundApprovalRequestsAsync` strips every approval request once
they are all auto-approved -- the case the loop exists to avoid. This mirrors the
Python behaviour, which logs and issues one final request with tools disabled
once its iteration budget is spent (`_tools.py`).

Python is not affected: it caps at `DEFAULT_MAX_ITERATIONS` (40) and persists
`attempt_count` in the budget state across approval resumes, so a resumed run
continues the count rather than restarting it.

Tests: the runaway is reproduced on both the streaming and non-streaming paths
with an inner agent that never stops requesting an auto-approved tool. Inner
invocations equal the cap plus the final turn, and scale with the configured cap,
so the assertions fail if the bound is removed.

No sample changes: with the loop bounded, Agent_Step01, Agent_Step06,
Agent_Step07 and Hosted-AgentSkills are safe as written.

* .NET: Add Arrange/Act/Assert comments to the cap constructor test

Matches the test convention documented in dotnet/AGENTS.md and used by the
surrounding tests in this file.

* Increase default max auto approval iterations to 40

* Apply suggestion from @westey-m

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>

* Update comments in ToolApprovalAgent.cs

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
2026-08-05 19:33:51 +00:00
Evan Mattson 594954700a Python: Fix AG-UI conversation correlation across runs (#7430)
* Add single agent AGUI sample

* Fix AG-UI conversation correlation across runs

* Address PR review and code quality feedback

* Correlate AG-UI chat spans across runs

---------

Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-08-05 17:17:53 +00:00
SergeyMenshykh a4d4eafa5e Add CodeQL suppression comment for DevUI proxy validation (#7505)
The proxy target validation in ValidateProxyTarget already ensures
requests stay on the configured backend. Add an inline suppression
comment following the repo's established pattern.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4363ab44-4d9e-41a0-97d3-4ab0b973f0b2
2026-08-05 11:33:41 +00:00
SergeyMenshykh da056275e6 .NET: [Experimental] Extend A2A task store with isolation key scoping (#7504)
* .NET: Add tenant-scoped task store isolation for A2A hosting

Wrap ITaskStore with IsolationKeyScopedTaskStore when a
SessionIsolationKeyProvider is registered, mirroring the existing
session store isolation pattern. This ensures task operations are
scoped per tenant in multi-user deployments.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: adc30d6c-ce66-40bb-933e-9801c2156cda

* fix formatting issue

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: adc30d6c-ce66-40bb-933e-9801c2156cda
2026-08-05 11:33:11 +00:00
Peter Ibekwe 1da571860a Updating version for dotnet release 1.17.0 (#7514) 2026-08-04 21:04:06 +00:00
Peter Ibekwe d56e81357e Fail declarative workflows when an agent returns an error (#7497) 2026-08-04 17:23:49 +00:00
Evan Mattson 5f3ca8f93c Python: Fix AG-UI approval resume at the protocol boundary (#7480)
* Python: Fix Ollama approval resume message handling

* Python: Reject empty Ollama approval resume payload

* Python: Keep AG-UI approval controls out of provider input

* Python: Do not trust pending AG-UI tool results
2026-08-04 15:12:33 +00:00
Scarab Systems 4d3c7844d6 Python: Bound tool result compaction summaries (#7396)
* Python: bound tool result compaction summaries

Keep ToolResultCompactionStrategy from re-inserting oversized tool result payloads through the synthetic summary message by bounding the generated digest text.

Add regression coverage proving a large tool result is not embedded verbatim, keeps a bounded prefix, and marks truncation.

* Python: keep excluded tool results out of compaction digests

Build ToolResultCompactionStrategy digest content from messages still included in the group so a summary cannot restore payloads that an earlier compaction already excluded.

Use the strategy cap constant in the large-payload regression and add coverage for already-excluded tool results.

Validation: uv run pytest packages/core/tests/core/test_compaction.py -q -k 'tool_result_compaction'; uv run ruff check packages/core/agent_framework/_compaction.py packages/core/tests/core/test_compaction.py; uv run ruff format --check packages/core/agent_framework/_compaction.py packages/core/tests/core/test_compaction.py; uv run poe test -P core; uv run poe build -P core; env HOME=/tmp/sds-home XDG_CACHE_HOME=/tmp/sds-cache uv run poe test -A.

* Python: align compaction digest review cleanup

Align ToolResultCompactionStrategy's included-message filter with the module's existing EXCLUDED_KEY boolean semantics.

Make the large-payload regression size scale from _SUMMARY_MAX_CHARS so it continues to exercise truncation if the digest cap changes.

Validation: uv run pytest packages/core/tests/core/test_compaction.py -q -k 'tool_result_compaction'; uv run ruff check packages/core/agent_framework/_compaction.py packages/core/tests/core/test_compaction.py; uv run ruff format --check packages/core/agent_framework/_compaction.py packages/core/tests/core/test_compaction.py; uv run poe test -P core; uv run poe build -P core.

* Python: collapse tool result digest scan

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-08-04 15:05:02 +00:00
Eduard van Valkenburg 07511b80c9 Python: Prevent orphaned local approval responses (#7462)
* Python: Prevent orphaned local approval responses

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

Copilot-Session: 0efaca91-a0a7-46f4-9b81-022385607fe4

* Python: Clarify approval serialization boundaries

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

Copilot-Session: 0efaca91-a0a7-46f4-9b81-022385607fe4

---------

Copilot-Session: 0efaca91-a0a7-46f4-9b81-022385607fe4
2026-08-04 01:05:08 +00:00
CTW_CTWalk e84b5a07c1 Python: fix LocalEvaluator reporting zero-check items as passed (#7399)
LocalEvaluator.evaluate initialized item_passed to True and only ever
cleared it inside the loop over check results. With no checks configured
the loop never runs, so an item with zero scores was recorded as passed:
result_counts reported one pass, all_passed was True, and
raise_for_status() did not raise.

Initialize item_passed from bool(check_results) so an item with no
evaluated checks fails closed. This matches the .NET contract in this
repository, where AgentEvaluationResults.ItemPassed ends with
'return result.Metrics.Count > 0' and is pinned by
LocalEvaluator_WithZeroChecks_ItemsHaveZeroMetricsAndFailAsync.

Add a focused regression covering the counts, all_passed, the empty
score list, and raise_for_status(). Update the LocalEvaluator class and
evaluate() docstrings, which previously described the pass rule without
the zero-check case.

Fixes #7397
2026-08-04 00:23:08 +00:00
Evan Mattson 84d5a5eec1 Consolidate Dependabot dependency updates (#7445)
* Bump AgentMemory from 1.2.0 to 1.3.0

---
updated-dependencies:
- dependency-name: AgentMemory
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* .NET: consolidate #7280 AgentMemory.AgentFramework 1.3.0

* Bump github/codeql-action/init from 4.37.0 to 4.37.3

Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.0 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump astral-sh/setup-uv from 8.3.2 to 9.0.0

Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/11f9893b081a58869d3b5fccaea48c9e9e46f990...c771a70e6277c0a99b617c7a806ffedaca235ff9)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump github/codeql-action/analyze from 4.37.0 to 4.37.3

Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.0 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump actions/cache from 5.0.5 to 6.1.0

Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9)

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

Signed-off-by: dependabot[bot] <support@github.com>

* Bump actions/checkout from 6.0.2 to 7.0.1

Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...3d3c42e5aac5ba805825da76410c181273ba90b1)

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

Signed-off-by: dependabot[bot] <support@github.com>

* Bump astral-sh/setup-uv in /.github/actions/python-setup

Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/11f9893b081a58869d3b5fccaea48c9e9e46f990...c771a70e6277c0a99b617c7a806ffedaca235ff9)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump ty from 0.0.60 to 0.0.64 in /python

Bumps [ty](https://github.com/astral-sh/ty) from 0.0.60 to 0.0.64.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.60...0.0.64)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.65
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump prek from 0.4.10 to 0.4.11 in /python

Bumps [prek](https://github.com/j178/prek) from 0.4.10 to 0.4.11.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.10...v0.4.11)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump uv from 0.11.29 to 0.11.32 in /python

Bumps [uv](https://github.com/astral-sh/uv) from 0.11.29 to 0.11.32.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.11.29...0.11.32)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump ruff from 0.15.22 to 0.16.0 in /python

Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.22 to 0.16.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.22...0.16.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* update uv-build requirement in /python

---
updated-dependencies:
- dependency-name: uv-build
  dependency-version: 0.12.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Python: align workspace pins for #7436-#7439

* Python: support ty 0.0.64 diagnostics for #7436

* Python: apply Ruff 0.16 formatting for #7439

* Update workflow action version annotations

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 00:01:14 +00:00
Tao Chen 8d379168b2 Python: Improve python sample validation workflow (#7350)
* Add skill to replace hardcoded foundry project endpoint and model

* Include more samples and fix migration samples part 1

* Fix migration samples

* Replace Foundry hosted agent validation skill

* Fix hosted agent file sample

* Fix agent result format

* Reorganize jobs

* Update discovery heuristic for apps

* Split agents into even more jobs

* Add toolbox endpoint

* Add more pre configured resources

* Fix using deployed agent sample

* Add sample status

* Add playbook

* Exclude hidden folder in sample discovery

* Install autogen dependencies

* Grant azure search RBAC role

* Increase timeout for magentic

* Build search resouce id deterministically

* Remove grant in the workflow

* Move azure cli login closer to when the sample actually runs

* Refactor playbook

* Fix using deployed agent sample

* Actually save the playbooks

* Fix action syntax error

* Fix magentic sample

* Address copilot comments

* Fix link inspection

* Address comments

* Correct README

* Fix playbook path

* Remove trailing space
2026-08-03 22:28:53 +00:00
Evan Mattson 18997c2fde Python: Give the AG-UI Thread Snapshot lifecycle a single owner module (#7479)
* Python: Give the AG-UI Thread Snapshot lifecycle a single owner module

Both the agent and workflow runners independently implemented the thread
snapshot lifecycle: hydration replay, the load-once stored read, resume
message seeding, the stored/request/deferred-default state overlay, and
the save whose storage failures must never surface on an already-streamed
run. The two copies had already drifted in small ways (one hydrate helper
re-checked a store the caller had verified; the two cancelled-resume-id
helpers differed on missing-id handling).

Introduce ThreadSnapshotSession in _snapshot_session.py as the one owner
of that lifecycle, opened once per run and inert when no store or scope
is configured so callers stop branching on configuration. Rewire both
runners onto it, consolidate _cancelled_resume_interrupt_ids in
_run_common (defensive variant) and _event_messages_to_snapshot_dicts in
the new module, and delete the superseded per-runner copies. The session
interface is covered by dedicated tests; existing suites pin runner
behavior. Public exports are unchanged.

* Python: Narrow AG-UI event types in snapshot session tests

The hydration test accessed run_id, snapshot, and messages on values
typed as BaseEvent, which fails the tests/samples type checkers. Narrow
each event with isinstance assertions before reading its fields.
2026-08-03 21:15:39 +00:00
Vaibhav Patel 5cc1b8e3c3 Python: Add hosted agent sample for the agent harness (#7010)
* Python: Add hosted agent sample for the agent harness

* Disable file providers and fix call_server usage in hosted harness sample

Addresses PR review: disable the harness file-memory and file-access
providers so the headless sample doesn't expose file tools or write
outside storage/, and correct the app.py docstring to match
call_server.py (which takes no prompt argument).

* Python: update hosted harness sample for current APIs

---------

Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
2026-08-03 20:55:47 +00:00
Evan Mattson 9ce55cae00 Python: Remove dead AG-UI orchestration helpers and flatten subpackage (#7426)
The _orchestration/_helpers module had no production callers; its only
importer was its own test file. It also carried a stale fork of the live
metadata sanitization in _agent_run.py: the dead copy truncated oversized
values, behavior the live copy deliberately replaced with drop-plus-warning
because truncation can produce invalid JSON.

Move _tooling.py and _predictive_state.py to the package root and remove
the now-empty _orchestration subpackage. Public exports are unchanged.
2026-08-03 20:46:05 +00:00
Evan Mattson 06c0fc2b10 Python: forward Azure AI Search query-source identity (#7278)
* Forward Azure AI Search query-source identity

* Address query source credential review feedback
2026-08-03 20:45:40 +00:00
338 changed files with 36852 additions and 11302 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version-file: "python/pyproject.toml"
enable-cache: true
+3 -3
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -85,7 +85,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout GitHub automation
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
sparse-checkout: |
@@ -165,7 +165,7 @@ jobs:
steps:
# Safe checkout: base repo only, not the untrusted PR head.
- name: Checkout target repo base
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 0
@@ -174,7 +174,7 @@ jobs:
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
- name: Checkout DevFlow
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -189,7 +189,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.x"
enable-cache: true
+5 -5
View File
@@ -40,7 +40,7 @@ jobs:
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -103,7 +103,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -177,7 +177,7 @@ jobs:
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -355,7 +355,7 @@ jobs:
env:
configuration: Release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -488,7 +488,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -43,7 +43,7 @@ jobs:
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
environment: 'integration'
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -25,7 +25,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@@ -42,7 +42,7 @@ jobs:
python-changes: ${{ steps.detect-changes.outputs.python }}
steps:
- name: Check out trusted workflow helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
+4 -4
View File
@@ -68,7 +68,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: |
.github/actions/github-app-token
@@ -135,7 +135,7 @@ jobs:
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -143,7 +143,7 @@ jobs:
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -158,7 +158,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.x"
enable-cache: true
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
issues: write
steps:
- name: Checkout GitHub automation
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: |
.github/actions/github-app-token
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github/scripts
fetch-depth: 1
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
@@ -82,7 +82,7 @@ jobs:
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -42,7 +42,7 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
@@ -68,7 +68,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -97,7 +97,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -128,7 +128,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -25,7 +25,7 @@ jobs:
# installability starts differing across supported Python versions.
UV_PYTHON: "3.13"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version-file: "python/pyproject.toml"
enable-cache: true
+10 -10
View File
@@ -48,7 +48,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -127,7 +127,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -178,7 +178,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -192,7 +192,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -297,7 +297,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -347,7 +347,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -402,7 +402,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -449,7 +449,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -497,7 +497,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -63,7 +63,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
+11 -11
View File
@@ -41,7 +41,7 @@ jobs:
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
@@ -107,7 +107,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -154,7 +154,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -215,7 +215,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -285,7 +285,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -296,7 +296,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -413,7 +413,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -474,7 +474,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -545,7 +545,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -606,7 +606,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -660,7 +660,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+20 -20
View File
@@ -29,7 +29,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -87,7 +87,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -141,7 +141,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -185,7 +185,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -229,7 +229,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -274,7 +274,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -317,7 +317,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -356,7 +356,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -392,7 +392,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -428,7 +428,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -467,7 +467,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -513,7 +513,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -553,7 +553,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -590,7 +590,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -641,7 +641,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -681,7 +681,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -728,7 +728,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -772,7 +772,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -840,7 +840,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -904,7 +904,7 @@ jobs:
- validate-autogen-migration
- validate-semantic-kernel-migration
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download all validation reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -20,7 +20,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
env:
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Save the PR number to a file since the workflow_run event
# in the coverage report workflow does not have access to it
- name: Save PR number
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get GitHub automation token
id: github-auth
@@ -172,7 +172,7 @@ parsing a structured payload into a typed record), without coupling the holder t
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
`AgentSessionStore` key or a workflow checkpoint session id.
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
`UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal.
- Persist session/checkpoint state only after the run or stream has completed.
## E2E Code Samples
+42 -8
View File
@@ -120,6 +120,10 @@ Code-reading landmarks:
- `_process_model_function_calls(...)` handles only calls from a completed model response.
- `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch.
- `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer.
- `FunctionInvocationLayer._update_function_invocation_continuation_state(...)` updates continuation state after
every service response. Provider layers may override it to carry provider-specific continuation metadata into
the next service call, but must delegate to the base implementation so generic conversation continuation remains
synchronized with the active `AgentSession`.
### Approval pause and resume
@@ -331,6 +335,8 @@ that manually replay messages own the equivalent rule: do not resend an approval
- Service-managed continuation may omit inline reasoning/call items only when the hosted service already owns them.
- Missing non-reconstructable reasoning fails explicitly before a provider request instead of silently dropping the
content.
- Foundry clients do not request `reasoning.encrypted_content` implicitly; callers may opt in explicitly when the
selected deployment supports encrypted reasoning.
- Compaction preserves or excludes the complete reasoning/call/result group atomically.
### Approval request and resume
@@ -353,6 +359,20 @@ that manually replay messages own the equivalent rule: do not resend an approval
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
transcript items.
- A current hosted approval response must be sent once on the immediate resume request.
- AG-UI removes a local approval response from its request and snapshot replay when a terminal result belongs to an
already-consumed occurrence, including result-before-response replay. A client-authored result in the occurrence
that is still registered as pending does not prove completion: AG-UI removes that result, keeps the validated
response for local execution, and leaves hosted approval responses as provider protocol data.
- Hosted AG-UI approval interrupts expose an accept/reject decision only; argument edits are rejected because the
hosted provider executes the server-owned request rather than client-edited arguments.
- AG-UI tool approval resumes accept the standard `approved` decision and full-replacement `editedArgs` payload.
Existing MAF clients remain compatible through the `accepted` decision alias and direct partial argument edits.
- An AG-UI `cancelled` resume is a valid terminal decision, not a run error. In a resume covering parallel open
interrupts, resolved siblings still execute and cancelled calls do not. An identical cancellation retry during
the retained terminal window also completes normally without restoring authority.
- AG-UI Approval State capacity is enforced independently for each trusted application scope. Abandoned pending
authority expires after its configured window, and indeterminate execution records remain non-retryable until
their separate safety window permits reclamation. Reclamation never recreates approval authority.
- A server-issued approval request must not be replayed inline during service-side continuation.
- History providers may retain approval control contents in their backing store for audit, but base history replay
filters them before later model calls.
@@ -364,7 +384,13 @@ that manually replay messages own the equivalent rule: do not resend an approval
- Model-bound history contains one function call/result pair per completed logical occurrence.
- Append-only history must not replay stale approval request/response wrappers to the model.
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
- A streaming response rebuilt from updates by an intermediate middleware must carry over the inner response's
conversation id and its internal-conversation-id marker, so framework-managed continuation appends only the latest
message instead of replaying a transcript the provider already holds. The rebuilt response mirrors the inner
conversation id exactly, including clearing it, and never retains an id emitted by an earlier service call in the
same turn.
- A trusted terminal result consumes the corresponding approval authority in explicit stateless replay; a result in a
server-registered pending occurrence cannot consume that authority before local execution.
## Scenario-to-test matrix
@@ -439,6 +465,12 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
| Provider-injected approval tool | A tool added during `before_run` defers to in-run resolution, executes once, and emits one result. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes` |
| AG-UI provider boundary | Completed local approval controls from AG-UI request and snapshot replay are absent from raw chat-client input while deferred and hosted approvals keep their respective in-run/provider paths. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_does_not_forward_resolved_local_approval_control_to_chat_client`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_canonical_resume_preserves_hosted_approval_for_provider`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_removes_duplicate_completed_controls`, `packages/ag-ui/tests/ag_ui/test_run.py::test_filter_local_approval_responses_for_provider_pairs_reused_call_ids_by_occurrence`, `packages/ag-ui/tests/ag_ui/test_run.py::test_canonical_hosted_approval_resume_rejects_edited_arguments_without_mutating_pending` |
| AG-UI standard approval payload | Agent and workflow tool approvals emit canonical `tool_call` interrupts. `approved` plus full-replacement `editedArgs` executes once and replays idempotently, while legacy `accepted` plus direct partial edits remains supported. Hosted approvals remain decision-only. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_entry_applies_standard_full_replacement_edited_args`, `test_endpoint_agent_approval_replayed_standard_edited_resume_is_idempotent`, `test_endpoint_agent_approval_resume_entry_applies_edited_arguments`, `test_workflow_endpoint_emits_canonical_tool_approval_interrupt`, `test_workflow_endpoint_accepts_canonical_tool_approval_resume`, `test_workflow_endpoint_applies_canonical_approval_edited_args`, `test_workflow_endpoint_accepts_legacy_partial_approval_edits`, `test_workflow_endpoint_hosted_approval_rejects_argument_edits` |
| AG-UI cancellation | A cancelled interrupt executes zero times and completes normally, including an identical retry during retained cancellation state; resolved siblings in the same complete resume still execute once. Workflow cancellation clears both runner correlation and the owning agent executor's pending request so later approvals remain resumable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_cancelled_resume_entry_completes_without_execution`, `test_endpoint_agent_approval_replayed_cancellation_completes_idempotently`, `test_endpoint_agent_approval_mixed_cancelled_and_resolved_resume_executes_resolved_tool`, `test_endpoint_workflow_request_info_cancelled_resume_completes_normally`, `test_workflow_endpoint_cancelled_agent_approval_does_not_block_next_approval` |
| AG-UI approval retention and capacity | Pending authority expires automatically, indeterminate outcomes remain non-retryable until their safety window permits reclamation, and one trusted scope cannot consume another scope's occurrence quota. | `packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py::test_abandoned_pending_occurrence_expires_and_releases_capacity`, `test_indeterminate_occurrence_is_reclaimed_after_its_safety_window`, `test_capacity_is_enforced_per_trusted_scope` |
| AG-UI local executor unavailable on resume | A claimed local occurrence whose executor disappeared releases its unstarted claim, reports temporary unavailability, and remains safely retryable. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_remains_retryable_when_local_tool_is_temporarily_unavailable` |
| AG-UI forwarded execution interruption | A provider failure, cancellation, or stream close after forwarding an approval recovers the open occurrence as indeterminate when no idempotency key proves retry safety. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_hosted_approval_becomes_indeterminate_when_provider_stream_fails` |
### Errors, control flow, and limits
@@ -465,18 +497,20 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
| Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` |
| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` |
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| Streaming message injection with per-service-call persistence | A streaming response rebuilt from updates mirrors the inner conversation id exactly, including clearing it, and keeps its internal marker, so the next iteration appends only the latest message rather than replaying the whole turn on top of provider-held history, and never persists a conversation id from an earlier injected service call. | `packages/core/tests/core/test_middleware_with_chat.py::TestChatMiddleware::test_message_injection_middleware_streaming_preserves_inner_continuation_state`, `test_message_injection_middleware_streaming_keeps_service_conversation_id_external`, `test_message_injection_middleware_streaming_clears_conversation_id_when_final_call_has_none`, `test_message_injection_middleware_conversation_id_matches_across_streaming_modes`, `packages/core/tests/core/test_harness_agent.py::test_streaming_harness_tool_call_does_not_duplicate_transcript` |
| Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` |
| OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Foundry encrypted reasoning opt-in | Foundry clients omit `reasoning.encrypted_content` by default and preserve an explicit caller opt-in. | `packages/foundry/tests/foundry/test_foundry_chat_client.py::test_get_response_does_not_request_encrypted_reasoning_by_default`, `test_get_response_preserves_explicit_encrypted_reasoning_opt_in`, `packages/foundry/tests/foundry/test_foundry_agent.py::test_foundry_agent_basic_call_does_not_request_unsupported_encrypted_reasoning`, `test_foundry_agent_preserves_caller_requested_encrypted_reasoning`, `packages/foundry_hosting/tests/test_responses_int.py::TestReasoningHostedMcpReplay::test_second_turn_replays_mcp_call_with_encrypted_reasoning` |
| Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` |
| Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests |
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_resume_emits_tool_call_result`, `test_approval_resume_result_has_content`, `test_approval_resume_snapshot_replaces_approval_payload_with_tool_result`, `test_approval_resume_zero_updates_emits_tool_result` |
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `test_rejection_does_not_emit_tool_call_result`, `test_mixed_approve_reject_emits_only_approved_tool_result`, `test_resolve_approval_responses_returns_only_approved` |
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `test_resolve_approval_responses_preserves_follow_up_user_input_group` |
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `test_resolve_approval_responses_returns_failure_when_grouped_execution_raises` |
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approved_call_emits_one_live_result_under_original_identity`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_resume_persists_replayable_tool_results`, `test_endpoint_agent_approval_replayed_resume_entry_reprojects_retained_result` |
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_rejected_call_does_not_execute_or_emit_live_result`, `test_mixed_batch_preserves_approved_result_identity_and_order`, `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_rejection_releases_already_approved_sibling` |
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_follow_up_group_remains_in_history_without_live_tool_result` |
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_execution_failure_emits_one_terminal_error_result` |
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_no_approval_path_emits_no_approval_specific_duplicate_result` |
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |
+2 -1
View File
@@ -136,7 +136,8 @@ only to approved first-party endpoints.
| 15 | `core.in_memory_skills_source` | In-memory / programmatic skills | `agent_framework.InMemorySkillsSource` |
| 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` |
| 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` |
| 1831 | _reserved_ | core growth | — |
| 18 | `core.agent_hooks` | Agent Hooks middleware | `agent_framework.create_agent_hooks_middleware` |
| 1931 | _reserved_ | core growth | — |
| 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` |
| 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` |
| 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` |
+2 -1
View File
@@ -38,7 +38,7 @@
<PackageVersion Include="Google.GenAI" Version="1.6.0" />
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
<!-- Microsoft.Azure.* -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.61.0" />
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
@@ -104,6 +104,7 @@
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
<!-- Vector Stores -->
<PackageVersion Include="CommunityToolkit.VectorData.CosmosNoSql" Version="1.0.0" />
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
<!-- Agent SDKs -->
+1
View File
@@ -187,6 +187,7 @@
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider/AgentWithMemory_Step07_FileMemoryProvider.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step08_MemoryUsingCosmosNoSql/AgentWithMemory_Step08_MemoryUsingCosmosNoSql.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
+3
View File
@@ -26,6 +26,9 @@
<ItemGroup Condition="'$(InjectSharedDiagnosticIds)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\DiagnosticIds\*.cs" LinkBase="Shared\DiagnosticIds" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedUsage)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Usage\*.cs" LinkBase="Shared\Usage" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedRedaction)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Redaction\*.cs" LinkBase="Shared\Redaction" />
</ItemGroup>
@@ -532,6 +532,26 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "AgentWithMemory_Step08_MemoryUsingCosmosNoSql",
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step08_MemoryUsingCosmosNoSql",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT", "COSMOS_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "COSMOS_DATABASE_NAME"],
MustContain =
[
"First session:",
"Second session (recalling prior chat history from Cosmos DB):",
],
ExpectedOutputDescription =
[
"The output should contain two joke responses.",
"The first joke should be about a pirate (as explicitly requested).",
"The second joke should also be pirate-themed or similar to what the user likes, since chat history from the first session should be recalled from Cosmos DB.",
"The output should not contain error messages or stack traces.",
],
},
// ── AgentWithRAG ────────────────────────────────────────────────────
new SampleDefinition
-6
View File
@@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.16.0</VersionPrefix>
<VersionPrefix>1.17.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260730</DateSuffix>
<DateSuffix>260804</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.16.0</GitTag>
<GitTag>1.17.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
+1 -1
View File
@@ -228,7 +228,7 @@ dotnet run
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
## Troubleshooting
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -17,9 +17,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,9 +28,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -18,9 +18,9 @@ builder.Services.AddAGUIServer();
builder.WebHost.UseUrls("http://localhost:8888");
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -38,8 +38,8 @@
<ItemGroup>
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
Microsoft Agent Framework adapter. -->
<PackageReference Include="AgentMemory" Version="1.2.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
<PackageReference Include="AgentMemory" Version="1.3.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.3.0" />
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="CommunityToolkit.VectorData.CosmosNoSql" />
<PackageReference Include="Microsoft.Azure.Cosmos" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to persist chat history in Azure Cosmos DB for NoSQL using the ChatHistoryMemoryProvider.
// The agent can then use chat history from prior conversations to inform responses in new conversations.
using System.Text.Json;
using Azure.AI.Projects;
using Azure.Identity;
using CommunityToolkit.VectorData.CosmosNoSql;
using Microsoft.Agents.AI;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var embeddingDeploymentName = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-large";
var embeddingDimensions = 3072;
if (Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_DIMENSIONS") is string embeddingDimensionsValue &&
(!int.TryParse(embeddingDimensionsValue, out embeddingDimensions) || embeddingDimensions <= 0))
{
throw new InvalidOperationException("FOUNDRY_EMBEDDING_DIMENSIONS must be a positive integer.");
}
var cosmosEndpoint = Environment.GetEnvironmentVariable("COSMOS_ENDPOINT") ?? throw new InvalidOperationException("COSMOS_ENDPOINT is not set.");
var cosmosDatabaseName = Environment.GetEnvironmentVariable("COSMOS_DATABASE_NAME") ?? "agent-memory";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
using CosmosClient cosmosClient = new(
cosmosEndpoint,
credential,
new CosmosClientOptions
{
UseSystemTextJsonSerializerWithOptions = JsonSerializerOptions.Default,
});
DatabaseResponse databaseResponse = await cosmosClient.CreateDatabaseIfNotExistsAsync(cosmosDatabaseName);
VectorStore vectorStore = new CosmosNoSqlVectorStore(
databaseResponse.Database,
new CosmosNoSqlVectorStoreOptions
{
JsonSerializerOptions = JsonSerializerOptions.Default,
EmbeddingGenerator = aiProjectClient
.GetProjectOpenAIClient()
.GetEmbeddingClient(embeddingDeploymentName)
.AsIEmbeddingGenerator(),
});
var userId = $"sample-{Guid.NewGuid():N}";
// Create the agent and add the ChatHistoryMemoryProvider to store chat messages in Cosmos DB.
AIAgent agent = aiProjectClient
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { ModelId = deploymentName, Instructions = "You are good at telling jokes." },
Name = "Joker",
AIContextProviders = [new ChatHistoryMemoryProvider(
vectorStore,
collectionName: "chathistory",
vectorDimensions: embeddingDimensions,
// Callback to configure the initial state of the ChatHistoryMemoryProvider.
// The ChatHistoryMemoryProvider stores its state in the AgentSession and this callback
// will be called whenever the ChatHistoryMemoryProvider cannot find existing state in the session,
// typically the first time it is used with a new session.
_ => new ChatHistoryMemoryProvider.State(
// Configure the scope values under which chat messages will be stored.
// In this case, we are using a per-run user ID and a unique session ID for each new session.
storageScope: new() { UserId = userId, SessionId = Guid.NewGuid().ToString("N") },
// Configure the scope which would be used to search for relevant prior messages.
// In this case, we are searching for any messages for the user across all sessions.
searchScope: new() { UserId = userId }))]
});
// Start a new session for the agent conversation.
AgentSession session = await agent.CreateSessionAsync();
// Run the agent with the session that stores conversation history in Cosmos DB.
Console.WriteLine("First session:");
Console.WriteLine(await agent.RunAsync("I like jokes about Pirates. Tell me a joke about a pirate.", session));
// Start a second session. Since we configured the search scope to be across all sessions for the user,
// the agent should remember that the user likes pirate jokes.
AgentSession session2 = await agent.CreateSessionAsync();
// Run the agent with the second session.
Console.WriteLine("Second session (recalling prior chat history from Cosmos DB):");
Console.WriteLine(await agent.RunAsync("Tell me a joke that I might like.", session2));
@@ -0,0 +1,41 @@
# Agent with Memory Using Azure Cosmos DB for NoSQL
This sample uses `ChatHistoryMemoryProvider` with `CosmosNoSqlVectorStore` to persist chat history in Azure Cosmos DB for NoSQL and recall relevant messages in a new agent session.
## Features Demonstrated
- Authenticating to Microsoft Foundry and Azure Cosmos DB with `DefaultAzureCredential`
- Storing chat messages in an Azure Cosmos DB vector store
- Creating the configured database and chat-history container when they do not exist
- Recalling relevant chat history across agent sessions
## Prerequisites
1. [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
2. A Microsoft Foundry project with:
- A chat model deployment (the default is `gpt-5.4-mini`)
- A `text-embedding-3-large` deployment with 3,072 dimensions
3. An Azure Cosmos DB for NoSQL account with [vector search enabled](https://learn.microsoft.com/azure/cosmos-db/nosql/vector-search)
4. An Azure identity that can create the configured database and container and read and write items
5. Azure CLI authentication (`az login`)
## Configuration
Set the following environment variables:
| Variable | Description | Default |
|---|---|---|
| `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | *(required)* |
| `COSMOS_ENDPOINT` | Azure Cosmos DB account endpoint | *(required)* |
| `FOUNDRY_MODEL` | Chat model deployment name | `gpt-5.4-mini` |
| `FOUNDRY_EMBEDDING_MODEL` | Embedding model deployment name | `text-embedding-3-large` |
| `FOUNDRY_EMBEDDING_DIMENSIONS` | Number of dimensions produced by the embedding deployment | `3072` |
| `COSMOS_DATABASE_NAME` | Database used to store agent memory | `agent-memory` |
## Run the Sample
```bash
dotnet run
```
The first session stores the user's preference for pirate jokes. The second session uses a different `AgentSession` but the same per-run user search scope, allowing the agent to retrieve that preference from Azure Cosmos DB without recalling data from earlier sample runs.
@@ -11,6 +11,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|[File Based Memory](./AgentWithMemory_Step07_FileMemoryProvider/)|This sample demonstrates how to use the `FileMemoryProvider` to give an agent tools for storing and recalling memories as files, and how to configure the folder that those memory files are written to.|
|[Memory with Azure Cosmos DB for NoSQL](./AgentWithMemory_Step08_MemoryUsingCosmosNoSql/)|This sample demonstrates how to persist and retrieve chat history across sessions with Azure Cosmos DB for NoSQL.|
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
@@ -101,10 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -20,9 +20,9 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.T
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -50,9 +50,9 @@ var agent = new AzureOpenAIClient(
]);
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
@@ -13,9 +13,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,10 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -157,10 +157,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -725,7 +725,7 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead;
using var response = await client.SendAsync(
using var response = await client.SendAsync( // CodeQL [SM03781] False positive: ValidateProxyTarget confirms the target host, scheme, and port match the configured backend, so the user-supplied path and query cannot change the destination.
request, completionOption, context.RequestAborted).ConfigureAwait(false);
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
@@ -11,8 +11,16 @@ using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
// The terminal stream events are named the same in two namespaces this file pulls in, and the short
// name binds to the one the event objects are not. Naming them here keeps `is` checks against the
// types the response stream actually produces.
using ResponseCompletedEvent = Azure.AI.AgentServer.Responses.Models.ResponseCompletedEvent;
using ResponseFailedEvent = Azure.AI.AgentServer.Responses.Models.ResponseFailedEvent;
using ResponseIncompleteEvent = Azure.AI.AgentServer.Responses.Models.ResponseIncompleteEvent;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
@@ -112,16 +120,19 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared
// by design — per-user isolation applies only when a user identity was resolved (hosted).
var conversationId = request.GetConversationId();
var sessionConversationId = HostedConversationKey.Resolve(
var agentSessionId = HostedConversationKey.Resolve(
conversationId, request.PreviousResponseId, context.ResponseId);
var chatClientAgent = agent.GetService<ChatClientAgent>();
var agentOptions = agent.GetService<ChatClientAgentOptions>();
var hostingOptions = this._serviceProvider.GetService<IOptions<FoundryResponsesOptions>>()?.Value;
var allowStoredOutputEnabled = hostingOptions?.AllowStoredOutputEnabled ?? false;
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// Load the session for this conversation, or start a new one. The store returns null when
// nothing is persisted for the key, so a fresh conversation and a resumed one both end up with
// a session to run against.
AgentSession? session = !string.IsNullOrWhiteSpace(agentSessionId)
? await sessionStore.GetOrCreateSessionAsync(agent, agentSessionId, resolvedUserId, cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// Capture the platform per-request call id (x-agent-foundry-call-id, protocol 2.0.0 only).
// It is re-applied to the ambient HostedCallContext immediately before each outbound egress
@@ -160,24 +171,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
yield return stream.EmitCreated();
yield return stream.EmitInProgress();
// 4. Convert input: history + current input → ChatMessage[]
// 4. Convert input: the current input items become the run's messages. Earlier turns are not
// added here; whatever holds the history for this agent supplies them, see step 5.
var messages = new List<ChatMessage>();
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = (!string.IsNullOrWhiteSpace(conversationId) || !string.IsNullOrWhiteSpace(request.PreviousResponseId))
&& session?.StateBag?.Count > 0;
if (!isResume)
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
@@ -191,7 +188,10 @@ public class AgentFrameworkResponseHandler : ResponseHandler
}
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
var chatOptions = InputConverter.ConvertToChatOptions(
request,
agentOptions?.ChatOptions?.RawRepresentationFactory,
hostingOptions);
chatOptions.Instructions = request.Instructions;
// Inject Foundry Toolbox tools when the toolbox service is available.
@@ -338,6 +338,23 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var options = new ChatClientAgentRunOptions(chatOptions);
// We only use a volatile provider for the conversation history if the agent is a ChatClientAgent and the allow setting is not intentionally set or not custom chat history provider is intentionally supplied.
var useVolatileChatHistoryProvider =
!allowStoredOutputEnabled
&& agent.GetService<ChatClientAgent>() is not null
&& agentOptions?.ChatHistoryProvider is null;
// This will create a temporary in-memory provider for the conversation history, which will be dropped at the end of this run.
// This is used to avoid storing the conversation history as the SDK will by default do the same via the (InMemory/Foundry)ResponsesProvider internal implementation.
if (useVolatileChatHistoryProvider)
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
options.AdditionalProperties ??= [];
options.AdditionalProperties.Add<ChatHistoryProvider>(
new VolatileChatHistoryProvider(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag)));
}
// 6. Set up consent context for -32006 OAuth consent interception.
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
@@ -350,6 +367,22 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
bool notAllowedStoreUsageDetected = false;
// Set when this turn is being failed, so its session is not kept. A turn that ends incomplete,
// waiting on OAuth consent or interrupted by a shutdown, is not a failure: the caller comes back
// for it and needs the state that was built up, the tool approval ids among it.
bool turnFailed = false;
// A successful terminal event, held until the run is wound up and the session can be checked.
ResponseStreamEvent? completedEvent = null;
// Check whenever the agent is storing messages when it should not.
bool CheckNotAllowedStoreUsage() =>
// For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null.
// If for any reason this property is set it means that the storage setting was enabled when it shouldn't.
!allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null };
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
@@ -418,6 +451,17 @@ public class AgentFrameworkResponseHandler : ResponseHandler
if (failedEvent is not null)
{
// The run may have failed precisely because the agent stored the turn: the session
// picks up that conversation id before the agent goes on to complain about having
// two history managers. Report the cause rather than the symptom.
if (CheckNotAllowedStoreUsage())
{
notAllowedStoreUsageDetected = true;
turnFailed = true;
throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
}
turnFailed = true;
yield return failedEvent;
yield break;
}
@@ -430,10 +474,21 @@ public class AgentFrameworkResponseHandler : ResponseHandler
yield break;
}
// A completed event is held back rather than sent straight out. The id of any
// conversation the agent's own service kept only lands on the session once the run is
// fully wound up, which is after this point, so sending the event now could tell the
// caller the turn finished and then hand them a failure for the very same turn.
if (evt is ResponseCompletedEvent)
{
completedEvent = evt;
emittedTerminal = true;
continue;
}
// yield is in the outer try (finally-only) — allowed by C#
yield return evt!;
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
if (evt is ResponseFailedEvent or ResponseIncompleteEvent)
{
emittedTerminal = true;
}
@@ -443,12 +498,32 @@ public class AgentFrameworkResponseHandler : ResponseHandler
{
await enumerator.DisposeAsync().ConfigureAwait(false);
// Persist session after streaming completes (successful or not). The user id partitions the
// persisted session per end user, mirroring the load above so multi-turn continuity is preserved.
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
// Only after the the agent ran when can check precisely if the session had been used to store messages in the backend for validation.
if (CheckNotAllowedStoreUsage())
{
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
notAllowedStoreUsageDetected = true;
turnFailed = true;
}
// Persist the session for the next turn of this conversation, unless this one is being failed.
if (session is not null && !turnFailed)
{
await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false);
}
}
if (notAllowedStoreUsageDetected)
{
this._logger.LogError(
"Agent '{AgentName}' should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent.",
agent.Name);
throw HostedStoredOutputCompatibility.CreateMisconfiguredAgentError();
}
if (completedEvent is not null)
{
yield return completedEvent;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
@@ -42,7 +43,8 @@ public abstract class AgentSessionStore
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a serialized agent session from persistent storage.
/// Retrieves a serialized agent session from persistent storage, or <see langword="null"/> when
/// no session is stored for the given identifiers.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
@@ -55,12 +57,41 @@ public abstract class AgentSessionStore
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the session, or a new session if not found.
/// A task that represents the asynchronous retrieval operation. The task result contains the restored
/// session, or <see langword="null"/> when nothing is stored for the given identifiers. This is a plain
/// lookup: it never creates a session. Use <see cref="GetOrCreateSessionAsync"/> to get a ready-to-use
/// session (loading an existing one or creating a new one), and use this method when the caller needs to
/// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it).
/// </returns>
public abstract ValueTask<AgentSession> GetSessionAsync(
public abstract ValueTask<AgentSession?> GetSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the stored session for the given identifiers, or creates a new one via
/// <see cref="AIAgent.CreateSessionAsync"/> when none is stored.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="userId">The per-user partition key; see <see cref="GetSessionAsync"/> for its meaning.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is always a usable session, never <see langword="null"/>.</returns>
/// <remarks>
/// This is the convenience path for callers that only need a session to work with and do not care whether
/// it was loaded or freshly created. It is implemented in terms of <see cref="GetSessionAsync"/>, so a
/// store overriding that method gets this behavior for free.
/// </remarks>
public virtual async ValueTask<AgentSession> GetOrCreateSessionAsync(
AIAgent agent,
string conversationId,
string? userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false)
?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -208,7 +208,7 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
$"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore).";
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
@@ -216,13 +216,13 @@ public sealed class FileSystemAgentSessionStore : AgentSessionStore
string path = this.GetSessionPath(agent, conversationId, userId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
return null;
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
return null;
}
// Parse and clone so the document buffer can be released.
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Options for hosting agents behind the Foundry Responses API.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FoundryResponsesOptions
{
/// <summary>
/// Gets or sets a value indicating whether the agent's own chat client is allowed to store the
/// responses it produces.
/// </summary>
/// <remarks>
/// <para>
/// A hosted turn is already recorded by the storage provider that runs around this handler, and
/// that record is the conversation the caller reads back. When the service behind the agent's chat
/// client also stores the turn, the same exchange is written a second time onto a trail of its own,
/// which nothing here reads and no one reconciles with the first.
/// </para>
/// <para>
/// While this is <see langword="false"/>, hosting turns that storage off for every run (the "store"
/// property in the JSON representation), and the readiness probe reports an agent whose
/// configuration would keep it on. Set it to <see langword="true"/> to leave the agent's own
/// setting exactly as the container configured it, in which case hosting neither changes it nor
/// checks it.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
public bool AllowStoredOutputEnabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include an encrypted version of reasoning tokens in
/// reasoning item outputs.
/// </summary>
/// <remarks>
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API
/// statelessly (like when the store parameter is set to false, or when an organization is enrolled
/// in the zero data retention program). It applies only while
/// <see cref="AllowStoredOutputEnabled"/> is <see langword="false"/>, because that is when hosting
/// turns storage off and the reasoning items would otherwise be lost between turns.
/// </remarks>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool IncludeReasoningEncryptedContent { get; set; } = true;
}
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Keeps the service behind a hosted agent's chat client from storing the responses it produces, and
/// reports the deployment that ends up storing them anyway.
/// </summary>
/// <remarks>
/// <para>
/// A hosted turn is already recorded by the AgentServer SDK's storage provider, which runs around the
/// handler, and that record is the conversation the caller reads back. A service that also stores the
/// turn writes the same exchange a second time onto a trail of its own, which nothing here reads and
/// no one reconciles with the first.
/// </para>
/// <para>
/// Turning storage off is a container concern, so a deployment that still stores is a server-side
/// misconfiguration rather than a bad request, and is reported as such.
/// </para>
/// </remarks>
internal static class HostedStoredOutputCompatibility
{
/// <summary>
/// HTTP status returned when the agent's own service stored the turn. <c>501 Not Implemented</c>
/// is a server-side classification, because the deployment, not the caller, is misconfigured; it is
/// also non-retryable and distinct from the generic <c>500</c> so it stands out in telemetry.
/// </summary>
internal const int MisconfiguredAgentStatusCode = 501;
/// <summary>
/// Stable error code emitted in the response body so callers and tooling can match the condition.
/// </summary>
internal const string MisconfiguredAgentErrorCode = "agent_stored_output_not_disabled";
/// <summary>
/// Returns the error to throw when the agent's own service kept the turn.
/// </summary>
internal static ResponsesApiException CreateMisconfiguredAgentError() =>
new(
new Error(
MisconfiguredAgentErrorCode,
"The agent should not have server side storage enabled. This produced a new untracked conversation/response in the server while the hosted agent also generated a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."),
MisconfiguredAgentStatusCode);
}
@@ -0,0 +1,163 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Reports, on the <c>GET /readiness</c> probe, a registered agent configured to have its own service
/// store the responses it produces, so a container that would record the conversation twice is caught
/// before it takes any traffic.
/// </summary>
/// <remarks>
/// <para>
/// Each agent is run for real, with its chat client replaced for that run by
/// <see cref="StoredOutputProbeChatClient"/>, which answers without calling anything. The run therefore
/// builds the very request the agent would have sent, and the probe reads the store setting off it.
/// Nothing hosting adds per request is applied here, so what the probe sees is how the container
/// configured its agent. Nothing leaves the container either.
/// </para>
/// <para>
/// Only a confirmed "this asks to be stored" fails the probe. An agent that is not a
/// <see cref="ChatClientAgent"/>, a request that carries no such setting, and a run that could not be
/// completed are all reported as healthy: this package cannot tell what those would do, and a
/// readiness probe is the wrong place to turn an uncertainty into an outage.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class HostedStoredOutputHealthCheck : IHealthCheck
{
private readonly IServiceProvider _serviceProvider;
private readonly FoundryResponsesOptions _hostingOptions;
private readonly ILogger<HostedStoredOutputHealthCheck>? _logger;
public HostedStoredOutputHealthCheck(
IServiceProvider serviceProvider,
IOptions<FoundryResponsesOptions>? hostingOptions = null,
ILogger<HostedStoredOutputHealthCheck>? logger = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
this._serviceProvider = serviceProvider;
this._hostingOptions = hostingOptions?.Value ?? new FoundryResponsesOptions();
this._logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
if (this._hostingOptions.AllowStoredOutputEnabled)
{
return HealthCheckResult.Healthy(
"The hosted agent backend storage usage was detected and the stored output enabled setting is explicitly allowing it.");
}
List<string> storingAgents = [];
var checkedAgents = 0;
foreach (var agent in this.ResolveAgents())
{
if (agent.GetService<ChatClientAgent>() is null)
{
// Hosting only reaches the store setting through ChatClientAgent's chat options, so any
// other agent runs untouched and there is nothing to report.
continue;
}
checkedAgents++;
if (await this.StoresItsOwnResponsesAsync(agent, cancellationToken).ConfigureAwait(false))
{
storingAgents.Add(agent.Name ?? agent.Id);
}
}
if (storingAgents.Count > 0)
{
return new HealthCheckResult(
status: context.Registration.FailureStatus,
description: string.Create(
CultureInfo.InvariantCulture,
$"Stored output: {storingAgents.Count} registered agent(s) should not have server side storage enabled. This will produce a new untracked conversation/response in the server while the hosted agent will also generate a conversation for the request of the agent. This setting is only allowed when enabling the FoundryResponsesOptions.AllowStoredOutputEnabled flag, which leaves the agent's own storage setting untouched and keeps that second recording on purpose."),
data: new Dictionary<string, object>(StringComparer.Ordinal) { ["storingAgents"] = storingAgents });
}
return HealthCheckResult.Healthy(
string.Create(CultureInfo.InvariantCulture, $"Stored output: {checkedAgents} agent(s) checked, none asking to store responses of their own."));
}
/// <summary>
/// Runs the agent with its chat client replaced by one that calls nothing, and reports whether the
/// request the agent built asks for the response to be stored.
/// </summary>
/// <remarks>
/// The run carries no chat options of its own, so the agent's own configuration is what reaches the
/// probe. Overriding the setting here, the way the request handler does per turn, would only show
/// the override back.
/// <para>
/// The agent's chat history provider is stood down for this run, because it would otherwise read
/// and write its own store on every readiness probe. A provider backed by a database would then be
/// doing external calls, and adding this probe's empty turn to a real conversation, for a run that
/// asks the agent nothing.
/// </para>
/// </remarks>
private async Task<bool> StoresItsOwnResponsesAsync(AIAgent agent, CancellationToken cancellationToken)
{
var probe = new StoredOutputProbeChatClient();
var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = _ => probe };
runOptions.AdditionalProperties ??= [];
runOptions.AdditionalProperties.Add<ChatHistoryProvider>(new VolatileChatHistoryProvider());
try
{
await agent.RunAsync([], options: runOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
// The agent could not complete a run it was never really asked to answer, which says nothing
// about how it stores responses and is not held against it. A cancellation of its own, a
// timeout inside the agent for instance, lands here too; only the health check's own
// cancellation is left to propagate.
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(ex, "Could not probe the stored output setting for agent '{AgentName}'.", agent.Name);
}
return false;
}
if (probe.StoredOutputEnabled is null && this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"Agent '{AgentName}' builds a request whose stored output setting could not be determined.",
agent.Name);
}
return probe.StoredOutputEnabled is true;
}
/// <summary>
/// Every agent this container can serve: the ones registered under a name, plus the default.
/// </summary>
private List<AIAgent> ResolveAgents()
{
var agents = new List<AIAgent>(this._serviceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey));
if (this._serviceProvider.GetService<AIAgent>() is { } defaultAgent && !agents.Contains(defaultAgent))
{
agents.Add(defaultAgent);
}
return agents;
}
}
@@ -40,16 +40,15 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentSession?> GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default)
{
var key = GetKey(agent, conversationId, userId);
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
return sessionContent switch
if (!this._sessions.TryGetValue(key, out var existingSession))
{
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
return null;
}
return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store
@@ -7,7 +7,11 @@ using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using OpenAI.Responses;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using MessageRole = Azure.AI.AgentServer.Responses.Models.MessageRole;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -87,10 +91,21 @@ internal static class InputConverter
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
/// </summary>
/// <param name="request">The create response request.</param>
/// <param name="agentRawRepresentationFactory">
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any, so a request that has
/// to set one of its own can run it rather than replace it.
/// </param>
/// <param name="hostingOptions">
/// How this container was configured. When it allows the agent's own service to store responses,
/// the setting is left exactly as the container configured it.
/// </param>
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
public static ChatOptions ConvertToChatOptions(CreateResponse request)
public static ChatOptions ConvertToChatOptions(
CreateResponse request,
Func<IChatClient, object?>? agentRawRepresentationFactory = null,
FoundryResponsesOptions? hostingOptions = null)
{
return new ChatOptions
var options = new ChatOptions
{
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
@@ -100,6 +115,22 @@ internal static class InputConverter
// the client-provided model would override it (causing failures when
// clients send placeholder values like "hosted-agent").
};
if (hostingOptions?.AllowStoredOutputEnabled is true)
{
// The container opted into keeping its own recording, so nothing here touches the setting,
// not even to pass the agent's own factory along: leaving it unset lets ChatClientAgent fall
// back to the agent's untouched.
return options;
}
// The caller's own store flag is not carried across, this setting is the responsibility of the hosted agent implementation.
DisableStoredOutput(
options,
agentRawRepresentationFactory,
hostingOptions?.IncludeReasoningEncryptedContent ?? true);
return options;
}
/// <summary>
@@ -133,6 +164,64 @@ internal static class InputConverter
return markers;
}
/// <summary>
/// Installs a factory on <paramref name="options"/> that turns storage off on the request the agent's
/// chat client is about to build.
/// </summary>
/// <param name="options">The chat options for this run.</param>
/// <param name="agentRawRepresentationFactory">
/// The factory the agent carries on its own <see cref="ChatOptions"/>, if any. It is invoked here and
/// its result is what gets the setting, because <c>ChatClientAgent</c> chains the two by taking the
/// agent's only when the request's returns null. A request factory that always answers would
/// otherwise drop whatever the container configured.
/// </param>
/// <param name="includeReasoningEncryptedContent">
/// Whether to ask for the encrypted form of the reasoning tokens, which is what keeps reasoning
/// usable across turns while storage is off.
/// </param>
/// <remarks>
/// Both OpenAI request shapes carry the setting, so a chat client speaking either protocol is
/// covered. Anything else is a request type with no notion of storing a response, and is handed back
/// untouched.
/// </remarks>
private static void DisableStoredOutput(
ChatOptions options,
Func<IChatClient, object?>? agentRawRepresentationFactory,
bool includeReasoningEncryptedContent)
{
options.RawRepresentationFactory = chatClient =>
{
switch (agentRawRepresentationFactory?.Invoke(chatClient))
{
case CreateResponseOptions responseOptions:
return LocalDisableStoredOutput(responseOptions, includeReasoningEncryptedContent);
case ChatCompletionOptions completionOptions:
completionOptions.StoredOutputEnabled = false;
return completionOptions;
case { } configuredByTheAgent:
return configuredByTheAgent;
default:
return LocalDisableStoredOutput(new CreateResponseOptions(), includeReasoningEncryptedContent);
}
};
static CreateResponseOptions LocalDisableStoredOutput(CreateResponseOptions responseOptions, bool includeReasoningEncryptedContent)
{
responseOptions.StoredOutputEnabled = false;
if (includeReasoningEncryptedContent &&
!responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent))
{
responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent);
}
return responseOptions;
}
}
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
{
return item switch
@@ -51,13 +51,18 @@ public static class FoundryHostingExtensions
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="configure">
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
/// agent's own service to store the responses it produces.
/// </param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services)
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action<FoundryResponsesOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryResponsesOptions(services, configure);
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
@@ -86,8 +91,16 @@ public static class FoundryHostingExtensions
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
/// <param name="configure">
/// Optional callback to configure <see cref="FoundryResponsesOptions"/>, for example to allow the
/// agent's own service to store the responses it produces.
/// </param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
public static IServiceCollection AddFoundryResponses(
this IServiceCollection services,
AIAgent agent,
AgentSessionStore? agentSessionStore = null,
Action<FoundryResponsesOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
@@ -95,6 +108,7 @@ public static class FoundryHostingExtensions
services.AddResponsesServer();
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryResponsesOptions(services, configure);
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
@@ -112,6 +126,41 @@ public static class FoundryHostingExtensions
return services;
}
/// <summary>
/// Applies the caller's <see cref="FoundryResponsesOptions"/> and registers the readiness check that
/// reports an agent configured to have its own service store the responses it produces.
/// </summary>
/// <remarks>
/// The check is registered on the same <c>/readiness</c> pipeline that <see cref="MapFoundryResponses"/>
/// maps, so a container that would record the conversation twice never takes traffic.
/// <c>AddCheck</c> does not dedupe by name, so a repeated registration is guarded here.
/// </remarks>
private static void ConfigureFoundryResponsesOptions(IServiceCollection services, Action<FoundryResponsesOptions>? configure)
{
if (configure is not null)
{
services.Configure(configure);
}
const string HealthCheckName = "foundry-stored-output";
services.Configure<HealthCheckServiceOptions>(opts =>
{
foreach (var existing in opts.Registrations)
{
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
{
return;
}
}
opts.Registrations.Add(new HealthCheckRegistration(
name: HealthCheckName,
factory: sp => ActivatorUtilities.CreateInstance<HostedStoredOutputHealthCheck>(sp),
failureStatus: HealthStatus.Unhealthy,
tags: ["foundry", "responses", "readiness"]));
});
}
/// <summary>
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using OpenAI.Responses;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A chat client that answers without calling anything, and records whether the request it was handed
/// asks for the response to be stored.
/// </summary>
/// <remarks>
/// <para>
/// Used by <see cref="HostedStoredOutputHealthCheck"/> to run an agent for real, through
/// <c>ChatClientAgentRunOptions.ChatClientFactory</c>, and see the request that agent builds on its own.
/// Nothing hosting would add later is applied here, so what this observes is the container's own
/// configuration. Nothing leaves the process either.
/// </para>
/// <para>
/// The reply carries no conversation id, so the agent is not led to believe a service kept the
/// conversation.
/// </para>
/// </remarks>
internal sealed class StoredOutputProbeChatClient : IChatClient
{
/// <summary>
/// Whether the observed request asked for the response to be stored, or <see langword="null"/> when
/// the run never reached the client or the request carries no such setting.
/// </summary>
public bool? StoredOutputEnabled { get; private set; }
/// <inheritdoc />
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
this.Observe(options);
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)));
}
/// <inheritdoc />
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
this.Observe(options);
yield return new ChatResponseUpdate(ChatRole.Assistant, string.Empty);
}
/// <inheritdoc />
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType?.IsInstanceOfType(this) is true ? this : null;
/// <inheritdoc />
public void Dispose()
{
}
private void Observe(ChatOptions? options)
{
// Building the request is what the agent's chat client would do next, so running the factory
// here shows the very setting that would have gone out.
var rawRepresentation = options?.RawRepresentationFactory?.Invoke(this);
this.StoredOutputEnabled = MapStoreSettingFromRawRepresentation(rawRepresentation);
}
/// <summary>
/// Reads whether a request the agent's chat client would send asks for the response to be stored.
/// Returns <see langword="null"/> when the request shape could not be inferred, which is a request
/// type this package has nothing to say about.
/// </summary>
private static bool? MapStoreSettingFromRawRepresentation(object? rawRepresentation) => rawRepresentation switch
{
// by default when the stored output setting is not set, the service stores the response, and we need to make the distinction from null (different request type)
// so we can throw the right error in the health check
CreateResponseOptions responseOptions => responseOptions.StoredOutputEnabled ?? true,
ChatCompletionOptions completionOptions => completionOptions.StoredOutputEnabled ?? true,
_ => null,
};
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A <see cref="ChatHistoryProvider"/> that holds a conversation in a field, for the lifetime of one
/// request and no longer.
/// </summary>
/// <remarks>
/// <para>
/// A hosted agent's conversation is recorded by the AgentServer SDK's own storage provider, which
/// writes every turn the caller asked it to store and serves it back through
/// <see cref="Azure.AI.AgentServer.Responses.ResponseContext.GetHistoryAsync"/>. That happens around
/// the handler, not through it. An agent with no provider of its own is given that record here, so it
/// reads the conversation the way it reads any other history, and anything it stores back is dropped
/// with this instance rather than kept somewhere the storage provider never sees.
/// </para>
/// <para>
/// Within a single run the provider still does its ordinary work: an agent calling tools goes back to
/// the chat client several times, and each of those calls needs the messages the earlier ones produced.
/// Those live here until the run ends and the instance is dropped.
/// </para>
/// <para>
/// Supplied as a run-scoped override through <see cref="AgentRunOptions.AdditionalProperties"/>, so it
/// serves the turn without changing the agent. An agent that does not read its history through a
/// provider ignores it.
/// </para>
/// </remarks>
internal sealed class VolatileChatHistoryProvider : ChatHistoryProvider
{
private readonly List<ChatMessage> _messages;
/// <summary>
/// Initializes a new instance of the <see cref="VolatileChatHistoryProvider"/> class holding the
/// conversation so far.
/// </summary>
/// <param name="history">The turns of this conversation the hosting service has recorded.</param>
public VolatileChatHistoryProvider(IEnumerable<ChatMessage>? history = null)
{
this._messages = history is null ? [] : [.. history];
}
/// <inheritdoc />
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(this._messages);
/// <inheritdoc />
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
this._messages.AddRange(context.RequestMessages);
if (context.ResponseMessages is not null)
{
this._messages.AddRange(context.ResponseMessages);
}
return default;
}
}
@@ -30,19 +30,20 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
/// dimension, so when a persistent store is registered any caller who knows or
/// guesses another caller's <c>contextId</c> can resume that other caller's
/// persisted thread. Hosts that serve more than one user must compose a principal
/// dimension into the lookup key — typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <strong>Trust model.</strong> The A2A <c>contextId</c> and <c>taskId</c> arrive
/// from the wire and are treated as chain-resume identifiers — <em>not</em> as
/// authorization tokens. Both the <see cref="AgentSessionStore"/> and
/// <see cref="ITaskStore"/> contracts carry no principal/owner dimension by default,
/// so when a persistent store is registered any caller who knows or guesses another
/// caller's <c>contextId</c> or <c>taskId</c> can access that other caller's data.
/// Hosts that serve more than one user must compose a principal dimension into the
/// lookup key — typically by calling <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
/// conversation identifier, which is appropriate for first-run / single-user /
/// prototyping scenarios but unsafe for multi-user hosts.
/// <see cref="AgentIsolationKeyProvider"/>). When an <see cref="AgentIsolationKeyProvider"/>
/// is registered, both the session store and the task store are automatically wrapped
/// with tenant-scoped isolation. When no isolation provider is registered, behavior
/// is unchanged — the bare identifiers are used directly, which is appropriate for
/// first-run / single-user / prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// </remarks>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
@@ -65,10 +66,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -91,10 +92,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -116,10 +117,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -154,10 +155,10 @@ public static class A2AServerServiceCollectionExtensions
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -179,6 +180,8 @@ public static class A2AServerServiceCollectionExtensions
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
{
var isolationKeyProvider = serviceProvider.GetService<AgentIsolationKeyProvider>();
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
if (agentHandler is null)
{
@@ -186,7 +189,6 @@ public static class A2AServerServiceCollectionExtensions
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new NoopAgentSessionStore();
@@ -201,7 +203,13 @@ public static class A2AServerServiceCollectionExtensions
}
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
ITaskStore taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
// Wrap the task store with isolation key scoping, same as the session store above.
if (taskStore is not IsolationKeyScopedTaskStore)
{
taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null);
}
return new A2AServer(
agentHandler,
@@ -0,0 +1,217 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// A delegating <see cref="ITaskStore"/> that scopes task keys by an isolation key
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that tasks are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
/// <remarks>
/// <para>
/// This class mirrors the isolation pattern of <see cref="IsolationKeyScopedAgentSessionStore"/>
/// but applies it to the A2A task store, preventing cross-tenant task access in multi-tenant deployments.
/// </para>
/// <para>
/// Both the store key and the persisted <see cref="AgentTask.ContextId"/> are scoped with the isolation
/// key. Scoping the persisted context is what allows list queries to be constrained to the calling
/// tenant, because list results are matched against the task body rather than the store key. Scoped
/// values are stripped again before being returned, so callers only ever observe bare identifiers.
/// </para>
/// </remarks>
public sealed class IsolationKeyScopedTaskStore : ITaskStore
{
private readonly ITaskStore _innerStore;
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedTaskStore"/> class.
/// </summary>
/// <param name="innerStore">The underlying <see cref="ITaskStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="strict">
/// When <see langword="true"/>, an <see cref="InvalidOperationException"/> is thrown if the isolation key
/// cannot be determined. When <see langword="false"/>, the task ID is passed through unmodified.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
public IsolationKeyScopedTaskStore(
ITaskStore innerStore,
AgentIsolationKeyProvider? keyProvider,
bool strict)
{
ArgumentNullException.ThrowIfNull(innerStore);
this._innerStore = innerStore;
this._keyProvider = keyProvider;
this._strict = strict;
}
/// <inheritdoc />
public async Task<AgentTask?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
var task = await this._innerStore.GetTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
return task is null ? null : UnscopeTask(task, key);
}
/// <inheritdoc />
public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
await this._innerStore.SaveTaskAsync(ScopeId(taskId, key), ScopeTask(task, key), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
await this._innerStore.DeleteTaskAsync(ScopeId(taskId, key), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// <see cref="ListTasksResponse.TotalSize"/> is reported by the inner store. When no
/// <see cref="ListTasksRequest.ContextId"/> filter is supplied it therefore counts tasks across all
/// isolation keys, because a wrapper cannot narrow the count without enumerating the whole store.
/// The returned tasks themselves are always constrained to the current isolation key.
/// </remarks>
public async Task<ListTasksResponse> ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
if (key is not null && !string.IsNullOrEmpty(request.ContextId))
{
// Clone the request to avoid mutating the caller's object.
request = CloneRequestWithContextId(request, ScopeId(request.ContextId!, key));
}
var response = await this._innerStore.ListTasksAsync(request, cancellationToken).ConfigureAwait(false);
if (key is null)
{
return response;
}
// Tasks are persisted with a scoped ContextId, so any entry that does not carry the current
// isolation key belongs to another tenant and must not be returned.
var scopedTasks = new List<AgentTask>(response.Tasks.Count);
foreach (var task in response.Tasks)
{
if (IsInScope(task, key))
{
scopedTasks.Add(UnscopeTask(task, key));
}
}
response.Tasks = scopedTasks;
response.PageSize = scopedTasks.Count;
return response;
}
/// <summary>
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
/// </summary>
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
}
return key;
}
/// <summary>
/// Escapes special characters in the isolation key to ensure unambiguous scoped identifiers.
/// </summary>
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
/// <summary>
/// Prefixes a bare identifier with the escaped isolation key, or returns it unchanged when no key applies.
/// </summary>
private static string ScopeId(string id, string? key)
=> key is null ? id : $"{EscapeIsolationKey(key)}::{id}";
/// <summary>
/// Strips the isolation key prefix from a scoped identifier, or returns it unchanged when the prefix is absent.
/// </summary>
private static string UnscopeId(string scopedId, string? key)
{
if (key is null)
{
return scopedId;
}
string prefix = $"{EscapeIsolationKey(key)}::";
return scopedId.StartsWith(prefix, StringComparison.Ordinal)
? scopedId.Substring(prefix.Length)
: scopedId;
}
/// <summary>
/// Determines whether a persisted task carries the supplied isolation key.
/// </summary>
private static bool IsInScope(AgentTask task, string key)
=> task.ContextId?.StartsWith($"{EscapeIsolationKey(key)}::", StringComparison.Ordinal) == true;
/// <summary>
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> is scoped by the isolation key.
/// </summary>
/// <remarks>
/// The task instance is copied rather than mutated because the A2A server reuses it for live event
/// notification after persisting; mutating it would surface the scoped context on the wire.
/// </remarks>
private static AgentTask ScopeTask(AgentTask task, string? key)
=> key is null ? task : CloneTaskWithContextId(task, ScopeId(task.ContextId, key));
/// <summary>
/// Creates a copy of the task whose <see cref="AgentTask.ContextId"/> has the isolation key removed.
/// </summary>
private static AgentTask UnscopeTask(AgentTask task, string? key)
=> key is null ? task : CloneTaskWithContextId(task, UnscopeId(task.ContextId, key));
private static AgentTask CloneTaskWithContextId(AgentTask task, string contextId)
=> new()
{
Id = task.Id,
ContextId = contextId,
Status = task.Status,
History = task.History,
Artifacts = task.Artifacts,
Metadata = task.Metadata,
};
private static ListTasksRequest CloneRequestWithContextId(ListTasksRequest request, string contextId)
=> new()
{
ContextId = contextId,
Tenant = request.Tenant,
Status = request.Status,
PageSize = request.PageSize,
PageToken = request.PageToken,
HistoryLength = request.HistoryLength,
StatusTimestampAfter = request.StatusTimestampAfter,
IncludeArtifacts = request.IncludeArtifacts,
};
}
@@ -91,9 +91,9 @@ public static class AGUIEndpointRouteBuilderExtensions
/// principal dimension into the lookup key. The recommended way is to wrap the
/// keyed <see cref="AgentSessionStore"/> in
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
/// <see cref="AgentIsolationKeyProvider"/>) and registering the store via the
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
/// isolation provider is registered, behavior is unchanged — the bare
@@ -113,7 +113,7 @@ public static class AGUIEndpointRouteBuilderExtensions
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new NoopAgentSessionStore();
@@ -11,63 +11,63 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// An <see cref="AgentIsolationKeyProvider"/> that extracts an isolation key for agent-owned resources
/// from a claim in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// <para>
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// This provider is suitable for ASP.NET Core web applications where agent-owned resources are partitioned
/// by authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>
/// must uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
/// would receive the same isolation key and could read or overwrite one another's persisted data.
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// provider maps a different claim).
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
/// is missing, the provider returns <see langword="null"/>. Consuming stores then enforce strict or
/// pass-through behavior based on their configuration.
/// </para>
/// <para>
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
/// to provide access to the current <see cref="HttpContext"/>.
/// </para>
/// </remarks>
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
public class ClaimsIdentityAgentIsolationKeyProvider : AgentIsolationKeyProvider
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly string _claimType;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
/// </param>
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
/// <exception cref="ArgumentException">
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// </exception>
public ClaimsIdentitySessionIsolationKeyProvider(
public ClaimsIdentityAgentIsolationKeyProvider(
IHttpContextAccessor? httpContextAccessor,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
{
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
options ??= new ClaimsIdentityAgentIsolationKeyProviderOptions();
this._httpContextAccessor = httpContextAccessor;
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
}
/// <summary>
/// Extracts the session isolation key from the current user's claims.
/// Extracts the isolation key for agent-owned resources from the current user's claims.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -81,7 +81,7 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
/// multiple claims of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
{
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
@@ -5,12 +5,12 @@ using System.Security.Claims;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// Options for configuring <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderOptions
public class ClaimsIdentityAgentIsolationKeyProviderOptions
{
/// <summary>
/// Gets or sets the claim type to extract from the user's identity for session isolation.
/// Gets or sets the claim type to extract from the user's identity to isolate agent-owned resources.
/// </summary>
/// <remarks>
/// <para>
@@ -28,8 +28,8 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
/// across all callers. Two distinct principals that share the same value for a non-unique claim
/// would receive the same session-isolation key and could read or overwrite one another's
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
/// would receive the same agent-isolation key and could read or overwrite one another's
/// persisted data. Only override this value with a claim that is guaranteed unique and stable.
/// </para>
/// <para>
/// Common alternatives include:
@@ -13,11 +13,11 @@ namespace Microsoft.Agents.AI.Hosting;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate session isolation keys.
/// Registers an <see cref="AgentIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate isolation keys for agent-owned resources.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <param name="options">Optional configuration for the claims-based isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// <para>
@@ -31,24 +31,24 @@ public static class ServiceCollectionExtensions
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
/// such as Entra's <c>oid</c>) should override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode session stores to fail.
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode stores to fail.
/// </para>
/// <para>
/// <strong>Security warning:</strong> If you override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
/// the host can prove their uniqueness across all callers, because distinct principals that share the
/// same claim value would receive the same isolation key and could access one another's sessions.
/// same claim value would receive the same isolation key and could access one another's persisted data.
/// </para>
/// </remarks>
public static IServiceCollection UseClaimsBasedSessionIsolation(
public static IServiceCollection UseClaimsBasedAgentIsolation(
this IServiceCollection services,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
{
options ??= new();
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
ServiceDescriptor descriptor = new(typeof(AgentIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
services.Add(descriptor);
return services;
@@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions
{
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
return new ClaimsIdentityAgentIsolationKeyProvider(contextAccessor, options);
}
}
}
@@ -6,24 +6,26 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
/// Provides an abstract base class for resolving keys that isolate resources owned by hosted agents.
/// </summary>
/// <remarks>
/// <para>
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
/// implement the key resolution logic appropriate to their hosting environment.
/// The <c>Agent</c> prefix identifies the hosting API domain; it does not mean that agent instances
/// themselves are isolated. The returned key scopes agent-owned resources, such as sessions and A2A
/// tasks, to a logical partition (e.g., user ID, tenant ID, or composite key). Other agent resources,
/// such as memory or retrieval data, can use the same key when they require the same isolation boundary.
/// Derived classes implement the key resolution logic appropriate to their hosting environment.
/// </para>
/// <para>
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
/// to unscoped storage based on its configuration.
/// Consuming stores can then enforce strict behavior (throwing an exception) or fall back to unscoped
/// storage based on their configuration.
/// </para>
/// </remarks>
public abstract class SessionIsolationKeyProvider
public abstract class AgentIsolationKeyProvider
{
/// <summary>
/// Asynchronously retrieves the session isolation key for the current request or execution context.
/// Asynchronously retrieves the isolation key for agent-owned resources in the current request or execution context.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -35,5 +37,5 @@ public abstract class SessionIsolationKeyProvider
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
/// the caller to decide on strict vs. pass-through behavior.
/// </remarks>
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
public abstract ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default);
}
@@ -27,10 +27,10 @@ namespace Microsoft.Agents.AI.Hosting;
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>sessionStoreId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
/// <c>sessionStoreId</c> to include an isolation key resolved from an
/// <see cref="AgentIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentityAgentIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedAgentIsolation(...)</c>). When no provider is registered, the
/// store behaves as a single-namespace persistence layer — appropriate for
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
/// </para>
@@ -59,7 +59,7 @@ public static class HostedAgentBuilderExtensions
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
var isolationKeyProvider = sp.GetService<AgentIsolationKeyProvider>();
// Best efforts options getting
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
@@ -8,12 +8,12 @@ namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that sessions are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
@@ -21,7 +21,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </summary>
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
/// <exception cref="ArgumentNullException">
@@ -29,7 +29,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </exception>
public IsolationKeyScopedAgentSessionStore(
AgentSessionStore innerStore,
SessionIsolationKeyProvider? keyProvider,
AgentIsolationKeyProvider? keyProvider,
IsolationKeyScopedAgentSessionStoreOptions? options = null)
: base(innerStore)
{
@@ -51,12 +51,12 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
}
return key;
@@ -13,7 +13,7 @@ public class IsolationKeyScopedAgentSessionStoreOptions
/// <remarks>
/// <para>
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
/// when <see cref="AgentIsolationKeyProvider.GetIsolationKeyAsync"/> returns <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
@@ -32,9 +32,9 @@ namespace Microsoft.Agents.AI.Hosting;
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
/// <see cref="AgentIsolationKeyProvider"/>) so that the conversation namespace is
/// scoped per principal. See the trust-model remarks on
/// <see cref="AgentSessionStore"/> for the full background.
/// </para>
@@ -4,6 +4,7 @@ using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -132,13 +133,80 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
agent.RunStreamingAsync([], null, runOptions, cancellationToken);
await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false))
await foreach (AgentResponseUpdate update in WithFailureDetectionAsync(agentResponse, agentVersionResult.Name, cancellationToken).ConfigureAwait(false))
{
update.AuthorName = agentVersionResult.Name;
yield return update;
}
}
/// <summary>
/// Surfaces a failed Responses API run as <see cref="ErrorContent"/>.
/// </summary>
/// <remarks>
/// <para>
/// <c>Microsoft.Extensions.AI.OpenAI</c> maps the <c>response.failed</c> event onto a
/// contentless update, leaving a failed run indistinguishable from an empty successful one.
/// </para>
/// <para>
/// The failed update is replaced rather than supplemented: it carries the provider's error text
/// in its raw representation, and updates reach clients verbatim regardless of the host's
/// exception-detail policy.
/// </para>
/// </remarks>
internal static async IAsyncEnumerable<AgentResponseUpdate> WithFailureDetectionAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
string? authorName,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
update.AuthorName = authorName;
yield return TryCreateFailureUpdate(update, authorName, out AgentResponseUpdate? failureUpdate)
? failureUpdate
: update;
}
}
/// <summary>
/// Builds an <see cref="ErrorContent"/> update when <paramref name="update"/> represents a failed run.
/// </summary>
private static bool TryCreateFailureUpdate(
AgentResponseUpdate update,
string? authorName,
[NotNullWhen(true)] out AgentResponseUpdate? failureUpdate)
{
failureUpdate = null;
if (update.RawRepresentation is not ChatResponseUpdate chatUpdate ||
chatUpdate.RawRepresentation is not StreamingResponseFailedUpdate failedUpdate)
{
return false;
}
ResponseError? error = failedUpdate.Response?.Error;
// A failure with no detail must still explain itself to the client.
ErrorContent errorContent =
new(string.IsNullOrWhiteSpace(error?.Message) ? DefaultFailureMessage : error!.Message)
{
ErrorCode = error?.Code.ToString() is { Length: > 0 } code ? code : DefaultFailureCode,
};
failureUpdate =
new(ChatRole.Assistant, [errorContent])
{
AuthorName = authorName,
ResponseId = update.ResponseId ?? failedUpdate.Response?.Id,
CreatedAt = update.CreatedAt,
};
return true;
}
private const string DefaultFailureMessage = "The agent run failed.";
private const string DefaultFailureCode = "failed";
private async Task<ProjectsAgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
@@ -77,7 +77,9 @@ internal static class AgentProviderExtensions
updates.Add(update);
if (autoSend)
// Error updates are withheld: they reach the client verbatim, bypassing the host's
// exception-detail policy. The detail still arrives via the thrown exception.
if (autoSend && !HasError(update))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
}
@@ -85,6 +87,9 @@ internal static class AgentProviderExtensions
AgentResponse response = updates.ToAgentResponse();
// Fail before the response is announced as completed or copied to the conversation.
ThrowIfFailed(response, agentName);
if (autoSend)
{
await context.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
@@ -111,4 +116,41 @@ internal static class AgentProviderExtensions
}
}
}
/// <summary>
/// Indicates whether an update carries an agent error rather than usable content.
/// </summary>
private static bool HasError(AgentResponseUpdate update) =>
update.Contents.Any(content => content is ErrorContent);
/// <summary>
/// Fails the action when the agent reported an error rather than a usable response.
/// </summary>
/// <remarks>
/// Any top-level <see cref="ErrorContent"/> is a failure, covering both a failed run and a
/// refusal, and excluding <c>incomplete</c>, which carries partial content instead. The detail
/// is folded into the exception so one error path stays under the host's exception-detail policy.
/// </remarks>
private static void ThrowIfFailed(AgentResponse response, string agentName)
{
// The last error wins: a run that fails without detail yields a generic placeholder first,
// and the specific cause follows as its own error.
ErrorContent? error =
response.Messages
.SelectMany(message => message.Contents)
.OfType<ErrorContent>()
.LastOrDefault();
if (error is null)
{
return;
}
string errorCode = string.IsNullOrWhiteSpace(error.ErrorCode) ? "unknown" : error.ErrorCode!;
string errorMessage = string.IsNullOrWhiteSpace(error.Message) ? "No error message was provided." : error.Message!;
// No inner exception: DeclarativeActionException is unwrapped to its inner exception when
// reported, which would discard this message.
throw new DeclarativeActionException($"Agent '{agentName}' failed [{errorCode}]: {errorMessage}");
}
}
@@ -2,29 +2,40 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static partial class StringExtensions
internal static class StringExtensions
{
#if NET
[GeneratedRegex(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Multiline)]
private static partial Regex TrimJsonDelimiterRegex();
#else
private static Regex TrimJsonDelimiterRegex() => s_trimJsonDelimiterRegex;
private static readonly Regex s_trimJsonDelimiterRegex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline);
#endif
private const string JsonDelimiter = "```";
public static string TrimJsonDelimiter(this string value)
{
value = value.Trim();
Match match = TrimJsonDelimiterRegex().Match(value);
return match.Success ?
match.Groups[1].Value.Trim() :
value;
// Scan linearly so malformed fenced input cannot trigger regex backtracking.
int openingDelimiterIndex = FindOpeningDelimiter(value);
if (openingDelimiterIndex < 0)
{
return value;
}
int contentIndex = openingDelimiterIndex + JsonDelimiter.Length;
while (contentIndex < value.Length && IsWordCharacter(value[contentIndex]))
{
contentIndex++;
}
while (contentIndex < value.Length && char.IsWhiteSpace(value[contentIndex]))
{
contentIndex++;
}
int closingDelimiterIndex = FindClosingDelimiter(value, contentIndex);
return closingDelimiterIndex < 0 ?
value :
value.Substring(contentIndex, closingDelimiterIndex - contentIndex).Trim();
}
public static FormulaValue ToFormula(this string? value) =>
@@ -34,6 +45,54 @@ internal static partial class StringExtensions
public static string FormatName(this string identifier) => FormatIdentifier(identifier, skipFirst: true);
private static int FindOpeningDelimiter(string value)
{
for (int index = 0; index <= value.Length - JsonDelimiter.Length; index++)
{
if ((index == 0 || value[index - 1] == '\n') && IsDelimiterAt(value, index))
{
return index;
}
}
return -1;
}
private static int FindClosingDelimiter(string value, int startIndex)
{
for (int index = startIndex; index <= value.Length - JsonDelimiter.Length; index++)
{
if (IsDelimiterAt(value, index) && IsLineEnd(value, index + JsonDelimiter.Length))
{
return index;
}
}
return -1;
}
private static bool IsDelimiterAt(string value, int index) =>
value[index] == '`' &&
value[index + 1] == '`' &&
value[index + 2] == '`';
private static bool IsLineEnd(string value, int index) =>
index == value.Length ||
value[index] == '\n' ||
(value[index] == '\r' && index + 1 < value.Length && value[index + 1] == '\n');
// Keep language qualifier handling compatible with .NET regex \w semantics.
private static bool IsWordCharacter(char value) =>
char.GetUnicodeCategory(value) is
UnicodeCategory.UppercaseLetter or
UnicodeCategory.LowercaseLetter or
UnicodeCategory.TitlecaseLetter or
UnicodeCategory.ModifierLetter or
UnicodeCategory.OtherLetter or
UnicodeCategory.NonSpacingMark or
UnicodeCategory.DecimalDigitNumber or
UnicodeCategory.ConnectorPunctuation;
private static string FormatIdentifier(string identifier, bool skipFirst = false)
{
string[] words = identifier.Split('_');
@@ -280,9 +280,8 @@ internal static class SemanticAnalyzer
if (!first.DerivesFromExecutor)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.NotAnExecutor,
DiagnosticDescriptors.ProtocolClassNotAnExecutor,
classLocation,
first.ClassName,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
@@ -290,7 +289,7 @@ internal static class SemanticAnalyzer
if (!first.IsPartialClass)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ClassMustBePartial,
DiagnosticDescriptors.ProtocolClassMustBePartial,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
@@ -104,4 +104,26 @@ internal static class DiagnosticDescriptors
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF008: Executor with protocol attributes must be partial.
/// </summary>
public static readonly DiagnosticDescriptor ProtocolClassMustBePartial = Register(new(
id: "MAFGENWF008",
title: "Executor with protocol attributes must be partial",
messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but is not declared as partial",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF009: Protocol attributes on non-Executor class.
/// </summary>
public static readonly DiagnosticDescriptor ProtocolClassNotAnExecutor = Register(new(
id: "MAFGENWF009",
title: "Protocol attributes on non-Executor class",
messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but does not derive from Executor",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true));
}
@@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows;
/// This attribute can be applied multiple times to declare multiple message types.
/// It is inherited by derived classes, allowing base executors to declare common message types.
/// </para>
/// <para>
/// When this attribute is applied to an executor class and the workflows source generator is referenced,
/// the class must be declared <c>partial</c> so the generator can add its protocol configuration.
/// </para>
/// </remarks>
/// <example>
/// <code>
@@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows;
/// This attribute can be applied multiple times to declare multiple output types.
/// It is inherited by derived classes, allowing base executors to declare common output types.
/// </para>
/// <para>
/// When this attribute is applied to an executor class and the workflows source generator is referenced,
/// the class must be declared <c>partial</c> so the generator can add its protocol configuration.
/// </para>
/// </remarks>
/// <example>
/// <code>
@@ -132,7 +132,7 @@ internal sealed class MessageMerger
_ = finishReasons.Add(response.FinishReason.Value);
}
usage = MergeUsage(usage, response.Usage);
usage = UsageAggregator.Combine(usage, response.Usage);
additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties);
}
@@ -219,7 +219,7 @@ internal sealed class MessageMerger
Messages = current.Messages.Concat(incoming.Messages).ToList(),
ResponseId = current.ResponseId,
RawRepresentation = rawRepresentation,
Usage = MergeUsage(current.Usage, incoming.Usage),
Usage = UsageAggregator.Combine(current.Usage, incoming.Usage),
};
}
@@ -269,40 +269,5 @@ internal sealed class MessageMerger
return merged;
}
static UsageDetails? MergeUsage(UsageDetails? current, UsageDetails? incoming)
{
if (current is null)
{
return incoming;
}
AdditionalPropertiesDictionary<long>? additionalCounts = current.AdditionalCounts;
if (incoming is null)
{
return current;
}
if (additionalCounts is null)
{
additionalCounts = incoming.AdditionalCounts;
}
else if (incoming.AdditionalCounts is not null)
{
foreach (string key in incoming.AdditionalCounts.Keys)
{
additionalCounts[key] = incoming.AdditionalCounts[key] +
(additionalCounts.TryGetValue(key, out long? existingCount) ? existingCount.Value : 0);
}
}
return new UsageDetails
{
InputTokenCount = current.InputTokenCount + incoming.InputTokenCount,
OutputTokenCount = current.OutputTokenCount + incoming.OutputTokenCount,
TotalTokenCount = current.TotalTokenCount + incoming.TotalTokenCount,
AdditionalCounts = additionalCounts,
};
}
}
}
@@ -8,6 +8,7 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedUsage>true</InjectSharedUsage>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
@@ -226,7 +227,10 @@ internal sealed class WorkflowTelemetryContext
{
return JsonSerializer.Serialize(value, value.GetType());
}
catch (JsonException)
// Telemetry must never fail workflow execution. Serialization can throw arbitrary exceptions
// (unregistered polymorphic types, reflection-disabled AOT, throwing property getters, custom
// converters), so fall back for every serialization failure.
catch (Exception)
{
return $"[Unserializable: {value.GetType().FullName}]";
}
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -238,6 +240,41 @@ public sealed class ChatClientAgentOptions
/// </value>
public bool DisableApprovalResponseBinding { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable bypassing that stores invocable (backend) function
/// calls in the session state and executes them on the next request when they are returned alongside
/// declaration-only (frontend) function calls in the same response.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> terminates the function-calling loop as soon as it encounters
/// a non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the caller
/// unexecuted. When the caller only resolves the declaration-only call (for example an AG-UI frontend
/// tool), the backend call's <c>call_id</c> is left orphaned, which causes the AI provider to reject the
/// next request.
/// </para>
/// <para>
/// When this property is set to <see langword="true"/>, an <see cref="InvocableFunctionBypassingChatClient"/>
/// decorator is injected above <see cref="FunctionInvokingChatClient"/> in the pipeline. For responses that
/// contain both invocable and declaration-only function calls, the decorator removes the invocable calls,
/// stores them in the session, and returns only the declaration-only calls to the caller. On the next
/// request the stored calls are re-injected as pre-approved responses so
/// <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add an <see cref="InvocableFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseInvocableFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableInvocableFunctionBypassing { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -258,5 +295,6 @@ public sealed class ChatClientAgentOptions
EnableMessageInjection = this.EnableMessageInjection,
DisableApprovalNotRequiredFunctionBypassing = this.DisableApprovalNotRequiredFunctionBypassing,
DisableApprovalResponseBinding = this.DisableApprovalResponseBinding,
EnableInvocableFunctionBypassing = this.EnableInvocableFunctionBypassing,
};
}
@@ -2,9 +2,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -221,4 +223,50 @@ public static class ChatClientBuilderExtensions
return builder.Use((innerClient, services) =>
new ApprovalResponseBindingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
/// <summary>
/// Adds an <see cref="InvocableFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline.
/// When <see cref="FunctionInvokingChatClient"/> returns a response containing both an invocable (backend)
/// <see cref="FunctionCallContent"/> and a declaration-only (frontend) <see cref="FunctionCallContent"/> in
/// the same iteration, this decorator removes the invocable calls, stores them in the session, and returns
/// only the declaration-only calls to the caller. On the next request the stored calls are re-injected as
/// pre-approved responses so <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </para>
/// <para>
/// If the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must be
/// positioned <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> that does not
/// correspond to a request it recorded, so that a forged approval cannot execute. The responses this decorator
/// injects are synthetic and have no such request, so placing the binding client below this decorator would
/// silently discard them and prevent the stored calls from ever executing.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.EnableInvocableFunctionBypassing"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator is intended for use within the context of a running <see cref="ChatClientAgent"/> with
/// an active session. When invoked outside of an agent run (for example when the built chat client is used
/// directly), the decorator becomes a no-op, passing the request through unchanged and logging a warning.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger for the decorator. When not provided,
/// the factory is resolved from the pipeline's <see cref="IServiceProvider"/>; if none is available,
/// logging is a no-op.
/// </param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseInvocableFunctionBypassing(this ChatClientBuilder builder, ILoggerFactory? loggerFactory = null)
{
return builder.Use((innerClient, services) =>
new InvocableFunctionBypassingChatClient(innerClient, loggerFactory ?? services.GetService<ILoggerFactory>()));
}
}
@@ -68,7 +68,7 @@ public static class ChatClientExtensions
// ApprovalNotRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding this decorator here, the resulting pipeline is:
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → FunctionInvokingChatClient
// [ApprovalResponseBindingChatClient] → ApprovalNotRequiredFunctionBypassingChatClient → [InvocableFunctionBypassingChatClient] → FunctionInvokingChatClient
// → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → DeferredOpenTelemetryChatClient → leaf IChatClient
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
// that don't actually require approval, storing them for automatic re-injection on the next request.
@@ -78,6 +78,18 @@ public static class ChatClientExtensions
new ApprovalNotRequiredFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}
// InvocableFunctionBypassingChatClient is opt-in via EnableInvocableFunctionBypassing. It is
// registered after the approval decorators and immediately before FunctionInvokingChatClient, so it
// sits directly above FICC (ChatClientBuilder.Build applies factories in reverse order). It intercepts
// FICC responses that contain both invocable (backend) and declaration-only (frontend) function calls,
// removes the invocable calls, stores them in the session, and re-injects them as pre-approved
// responses on the next request so FICC reconstructs and executes them.
if (options?.EnableInvocableFunctionBypassing is true)
{
chatBuilder.Use((innerClient, services) =>
new InvocableFunctionBypassingChatClient(innerClient, services.GetService<ILoggerFactory>()));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
@@ -0,0 +1,451 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that lets an agent expose both invocable (backend) tools and
/// declaration-only (frontend) tools at the same time, working around
/// <see cref="FunctionInvokingChatClient"/> terminating the function-calling loop before invoking
/// sibling backend tool calls when a declaration-only tool call appears in the same iteration.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> terminates the loop as soon as it encounters a
/// non-invocable (declaration-only) <see cref="FunctionCallContent"/>, returning every
/// <see cref="FunctionCallContent"/> in that iteration — including invocable backend calls — to the
/// caller unexecuted.
/// </para>
/// <para>
/// This decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline. On outbound
/// responses that contain <em>both</em> an invocable (backend) <see cref="FunctionCallContent"/> and a
/// declaration-only <see cref="FunctionCallContent"/>, it removes the invocable calls from the response,
/// stores them in the session's <see cref="AgentSessionStateBag"/>, and returns only the declaration-only
/// calls to the caller to resolve. On the next request — after the caller has resolved the
/// declaration-only calls — the stored invocable calls are re-injected as pre-approved
/// <see cref="ToolApprovalResponseContent"/> so that <see cref="FunctionInvokingChatClient"/> reconstructs
/// and executes them, producing the missing <see cref="FunctionResultContent"/>.
/// </para>
/// <para>
/// The stored calls are re-injected as approved <see cref="ToolApprovalResponseContent"/> rather than as
/// bare <see cref="FunctionCallContent"/> because <see cref="FunctionInvokingChatClient"/> only re-executes
/// calls that arrive from incoming history as approval responses; it does not execute bare
/// <see cref="FunctionCallContent"/> present in the history. This is the same mechanism used by
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, and a lone approved response without a
/// matching request in the history is accepted.
/// </para>
/// <para>
/// This decorator operates within the context of a running <see cref="AIAgent"/> with an active
/// <see cref="AgentRunContext.Session"/>. When invoked without an ambient run context or session
/// (for example when the chat client is used directly outside of an agent run), the decorator becomes
/// a no-op: it passes the request through to the inner client unchanged and logs a warning.
/// </para>
/// <para>
/// When the pipeline also contains an <see cref="ApprovalResponseBindingChatClient"/>, this decorator must sit
/// <em>below</em> it. That client drops any <see cref="ToolApprovalResponseContent"/> without a request it
/// recorded, so that a forged approval cannot execute; the responses injected here are synthetic and have no
/// such request. The default agent pipeline already orders them correctly.
/// </para>
/// </remarks>
internal sealed partial class InvocableFunctionBypassingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="AgentSessionStateBag"/> to store bypassed invocable function calls
/// between agent runs.
/// </summary>
internal const string StateBagKey = "_bypassedInvocableFunctionCalls";
private readonly ILogger _logger;
private bool _warnedNoSession;
/// <summary>
/// Initializes a new instance of the <see cref="InvocableFunctionBypassingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> used to create a logger for diagnostics.</param>
public InvocableFunctionBypassingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null)
: base(innerClient)
{
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<InvocableFunctionBypassingChatClient>();
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
if (!this.TryGetSession(out var session))
{
return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
}
messages = InjectPendingBypassedCalls(messages, session);
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
this.RemoveAndStoreBypassableInvocableCalls(response.Messages, options, session);
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (!this.TryGetSession(out var session))
{
await foreach (var passthrough in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
yield return passthrough;
}
yield break;
}
messages = InjectPendingBypassedCalls(messages, session);
// Stream updates live until a surfaced (non-informational) FunctionCallContent appears, then hold the
// tail so the strip/store decision can observe every call in the same batch before re-emitting.
// No whole-response coalescing is required because FunctionInvokingChatClient emits each call as a
// complete FunctionCallContent (it never splits a call across updates).
//
// FunctionInvokingChatClient buffers per iteration: it yields its buffered updates at the end of each
// iteration and then streams the next iteration live. When it invokes a call locally it flips
// FunctionCallContent.InformationalOnly to true in place, on the very instances held here. A buffered
// tail whose calls have all become informational therefore has nothing left to strip, so it is
// released and live streaming resumes. Only a genuinely bypassable batch (where the loop terminated,
// leaving the calls non-informational) is held to the end of the stream.
List<ChatResponseUpdate>? tail = null;
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (tail is not null && !ContainsNonInformationalFunctionCall(tail))
{
foreach (var buffered in tail)
{
yield return buffered;
}
tail = null;
}
if (tail is null && !UpdateHasNonInformationalFunctionCall(update))
{
yield return update;
continue;
}
(tail ??= []).Add(update);
}
if (tail is null)
{
yield break;
}
var contentLists = new IList<AIContent>[tail.Count];
for (int i = 0; i < tail.Count; i++)
{
contentLists[i] = tail[i].Contents;
}
this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
// Every buffered update is surfaced, including any left with no contents by the stripping above. An
// update carries metadata beyond its contents — ConversationId, ContinuationToken, ResponseId,
// MessageId, RawRepresentation and more — so dropping one would discard state the caller needs, and a
// content-free update is unremarkable in a stream. This differs from the non-streaming path, where an
// emptied message is removed because it would otherwise be persisted to history and resent to the
// provider on the next turn.
foreach (var update in tail)
{
yield return update;
}
}
/// <summary>
/// Attempts to get the current <see cref="AgentSession"/> from the ambient run context. When no run
/// context or session is available, logs a warning (once per instance) and returns <see langword="false"/>
/// so the caller can pass the request through without applying bypassing.
/// </summary>
private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
{
session = AIAgent.CurrentRunContext?.Session;
if (session is null)
{
if (!this._warnedNoSession)
{
this._warnedNoSession = true;
LogBypassingSkipped(this._logger);
}
return false;
}
return true;
}
[LoggerMessage(LogLevel.Warning, "InvocableFunctionBypassingChatClient was invoked without an active agent run context or session. Invocable function bypassing is skipped and all function calls are surfaced to the caller. Invoke the chat client through AIAgent.RunAsync or AIAgent.RunStreamingAsync to enable bypassing.")]
private static partial void LogBypassingSkipped(ILogger logger);
/// <summary>
/// Checks the session for invocable function calls stored on a previous turn and injects them as
/// a user message containing pre-approved <see cref="ToolApprovalResponseContent"/> items appended to
/// the input messages, so that <see cref="FunctionInvokingChatClient"/> reconstructs and executes them.
/// </summary>
/// <remarks>
/// <para>
/// Pending calls are consumed exactly once: the session entry is removed here and is never put back, so a
/// turn that fails or whose stream is abandoned drops them.
/// </para>
/// <para>
/// That is deliberate. The calls are injected as one batch and the service expects a
/// <see cref="FunctionResultContent"/> for every <see cref="FunctionCallContent"/> in it.
/// <see cref="FunctionInvokingChatClient"/> invokes approved approval responses before the main loop and
/// before any downstream service call, so by the time a request fails part of the batch has usually
/// already run — and because a failure surfaces as an exception rather than a response, the results of
/// those invocations are unrecoverable. Re-injecting the remainder would therefore still leave the batch
/// incomplete while invoking already-executed functions a second time. Dropping the calls avoids the
/// duplicate invocation, and matches
/// <see cref="ApprovalNotRequiredFunctionBypassingChatClient"/>, which likewise never restores its entry.
/// </para>
/// </remarks>
/// <param name="messages">The outgoing messages.</param>
/// <param name="session">The session holding any calls bypassed on a previous turn.</param>
private static IEnumerable<ChatMessage> InjectPendingBypassedCalls(
IEnumerable<ChatMessage> messages,
AgentSession session)
{
if (!session.StateBag.TryGetValue(
StateBagKey,
out List<FunctionCallContent>? pendingCalls,
AgentJsonUtilities.DefaultOptions)
|| pendingCalls is not { Count: > 0 })
{
return messages;
}
session.StateBag.TryRemoveValue(StateBagKey);
List<AIContent> approvalResponses = [];
foreach (var call in pendingCalls)
{
// FunctionInvokingChatClient reconstructs and executes the call from the approval response
// itself; the request is synthetic and does not need to be present in the history.
var request = new ToolApprovalRequestContent(ComposeApprovalRequestId(call.CallId), call);
approvalResponses.Add(request.CreateResponse(approved: true));
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
return messages.Concat([userMessage]);
}
/// <summary>
/// Composes the approval-request id for a bypassed call. The prefix deliberately differs from the
/// <c>ficc_</c> prefix <see cref="FunctionInvokingChatClient"/> uses for its own approval requests, so
/// that a synthetic id can never collide with a genuine one.
/// </summary>
private static string ComposeApprovalRequestId(string callId) => $"ifbcc_{callId}";
/// <summary>
/// Builds the set of invocable (backend) tool names and the set of declaration-only (frontend) tool
/// names from <see cref="ChatOptions.Tools"/> and <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
/// </summary>
private (HashSet<string> Invocable, HashSet<string> DeclarationOnly) GetToolNameSets(ChatOptions? options)
{
var ficc = this.GetService<FunctionInvokingChatClient>();
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
HashSet<string> invocable = new(StringComparer.Ordinal);
HashSet<string> declarationOnly = new(StringComparer.Ordinal);
foreach (var tool in allTools)
{
// AIFunction derives from AIFunctionDeclaration, so check the invocable type first.
if (tool is AIFunction function)
{
invocable.Add(function.Name);
}
else if (tool is AIFunctionDeclaration declaration)
{
declarationOnly.Add(declaration.Name);
}
}
return (invocable, declarationOnly);
}
/// <summary>
/// Returns <see langword="true"/> if the update contains a non-informational
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
/// <see cref="FunctionInvokingChatClient"/>).
/// </summary>
private static bool UpdateHasNonInformationalFunctionCall(ChatResponseUpdate update)
=> update.Contents.Any(c => c is FunctionCallContent { InformationalOnly: false });
/// <summary>
/// Returns <see langword="true"/> if any content list contains a non-informational
/// <see cref="FunctionCallContent"/> (a call that was surfaced to the caller rather than executed by
/// <see cref="FunctionInvokingChatClient"/>).
/// </summary>
private static bool ContainsNonInformationalFunctionCall(IList<AIContent>[] contentLists)
=> contentLists.Any(contents => contents.Any(c => c is FunctionCallContent { InformationalOnly: false }));
/// <summary>
/// Returns <see langword="true"/> if any buffered update still contains a non-informational
/// <see cref="FunctionCallContent"/>. Once <see cref="FunctionInvokingChatClient"/> has invoked a call it
/// flips <see cref="FunctionCallContent.InformationalOnly"/> to <see langword="true"/> in place, so a
/// buffer for which this returns <see langword="false"/> holds nothing that can be bypassed.
/// </summary>
private static bool ContainsNonInformationalFunctionCall(List<ChatResponseUpdate> updates)
=> updates.Any(UpdateHasNonInformationalFunctionCall);
/// <summary>
/// When a response contains both an invocable (backend) <see cref="FunctionCallContent"/> and a
/// declaration-only (frontend) <see cref="FunctionCallContent"/>, removes the invocable calls from the
/// response and stores them in the session for re-injection and execution on the next request.
/// </summary>
private void RemoveAndStoreBypassableInvocableCalls(
IList<ChatMessage> messages,
ChatOptions? options,
AgentSession session)
{
var contentLists = new IList<AIContent>[messages.Count];
for (int i = 0; i < messages.Count; i++)
{
contentLists[i] = messages[i].Contents;
}
var emptied = this.StripAndStoreBypassableInvocableCalls(contentLists, options, session);
if (emptied is null)
{
return;
}
// Remove messages that were emptied by stripping bypassed content (high index first). Messages that
// were already empty (for example metadata-only messages) are left untouched. Unlike a streaming
// update, an emptied message is worth removing because it would otherwise be persisted to the
// conversation history and resent to the provider on the next turn.
for (int i = messages.Count - 1; i >= 0; i--)
{
if (emptied.Contains(i))
{
messages.RemoveAt(i);
}
}
}
/// <summary>
/// Applies the both-kinds gate over the supplied content lists and, when both an invocable and a
/// declaration-only <see cref="FunctionCallContent"/> are present, removes the invocable calls in place
/// (in document order) and stores them in the session for re-injection on the next request.
/// </summary>
/// <returns>
/// The set of content-list indices that were emptied by the removal, or <see langword="null"/> when
/// nothing was bypassed.
/// </returns>
private HashSet<int>? StripAndStoreBypassableInvocableCalls(
IList<AIContent>[] contentLists,
ChatOptions? options,
AgentSession session)
{
// Cheap first pass: the common case is a text-only response with no function calls. Avoid allocating
// the tool-name sets unless the response actually contains at least one non-informational call.
if (!ContainsNonInformationalFunctionCall(contentLists))
{
return null;
}
var (invocable, declarationOnly) = this.GetToolNameSets(options);
if (invocable.Count == 0 || declarationOnly.Count == 0)
{
// The mixed backend/frontend scenario is impossible without at least one of each kind of tool.
return null;
}
bool hasInvocableCall = false;
bool hasDeclarationOnlyCall = false;
foreach (var contents in contentLists)
{
foreach (var content in contents)
{
if (content is FunctionCallContent { InformationalOnly: false } fcc)
{
if (declarationOnly.Contains(fcc.Name))
{
hasDeclarationOnlyCall = true;
}
else if (invocable.Contains(fcc.Name))
{
hasInvocableCall = true;
}
}
}
}
// Only bypass when the two kinds coexist in the same response. An all-invocable response is already
// handled by FunctionInvokingChatClient (never surfaced unexecuted), and an all-declaration-only
// response is the normal frontend-tools flow that must pass through unchanged.
if (!hasInvocableCall || !hasDeclarationOnlyCall)
{
return null;
}
List<FunctionCallContent>? bypassed = null;
HashSet<int>? emptied = null;
for (int i = 0; i < contentLists.Length; i++)
{
var contents = contentLists[i];
bool removedFromList = false;
// Forward scan collects the bypassed calls in document order.
for (int j = 0; j < contents.Count;)
{
if (contents[j] is FunctionCallContent { InformationalOnly: false } fcc
&& invocable.Contains(fcc.Name)
&& !declarationOnly.Contains(fcc.Name))
{
(bypassed ??= []).Add(fcc);
contents.RemoveAt(j);
removedFromList = true;
}
else
{
j++;
}
}
if (removedFromList && contents.Count == 0)
{
(emptied ??= []).Add(i);
}
}
if (bypassed is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, bypassed, AgentJsonUtilities.DefaultOptions);
}
return emptied;
}
}
@@ -77,22 +77,28 @@ public sealed class MessageInjectingChatClient : DelegatingChatClient
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
// Usage is accumulated across every iteration so the returned response reports the token cost
// of all service calls made, not just the last one.
UsageDetails? aggregatedUsage = null;
while (true)
{
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (HasActionableFunctionCalls(response.Messages))
{
return response;
return response.ApplyAggregatedUsage(aggregatedUsage);
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
if (await this.IsQueueEmptyAsync(session, cancellationToken).ConfigureAwait(false))
{
return response;
return response.ApplyAggregatedUsage(aggregatedUsage);
}
// Propagate any ConversationId returned by the service so subsequent iterations
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
@@ -17,6 +18,18 @@ namespace Microsoft.Agents.AI;
/// </remarks>
internal sealed class BackgroundAgentRuntimeState
{
/// <summary>
/// Gets an object used to synchronize access to the runtime references held by this instance.
/// </summary>
/// <remarks>
/// Background task registration happens on the agent's tool-invocation path, while
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/> may be called concurrently by a host.
/// All mutations of the dictionaries below, and of <see cref="IsReleased"/>, must be performed under this lock
/// so that a task can never be registered into an already-released runtime.
/// </remarks>
[JsonIgnore]
public object SyncRoot { get; } = new();
/// <summary>
/// Gets the mapping of task IDs to their in-flight <see cref="Task{AgentResponse}"/> instances.
/// </summary>
@@ -29,4 +42,39 @@ internal sealed class BackgroundAgentRuntimeState
/// </summary>
[JsonIgnore]
public Dictionary<int, AgentSession> BackgroundTaskSessions { get; } = [];
/// <summary>
/// Gets the mapping of task IDs to the <see cref="CancellationTokenSource"/> controlling their run.
/// </summary>
/// <remarks>
/// A source is created when a task is started or continued, and is disposed and removed when the task is
/// finalized, cleared, or when the session is released via <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/>.
/// </remarks>
[JsonIgnore]
public Dictionary<int, CancellationTokenSource> TaskCancellations { get; } = [];
/// <summary>
/// Gets or sets a value indicating whether this runtime has been released via
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/>.
/// </summary>
/// <remarks>
/// Once released, all in-flight tasks have been cancelled and awaited, and the runtime references have been
/// dropped. Tools that would start new background work refuse to run against a released runtime.
/// </remarks>
[JsonIgnore]
public bool IsReleased { get; set; }
/// <summary>
/// Gets or sets the completion signalled once the release of this runtime has finished all of its cleanup.
/// </summary>
/// <remarks>
/// Set under <see cref="SyncRoot"/> by the caller that first releases the runtime, and completed once that
/// caller has finished waiting for the in-flight tasks and has dropped the runtime references. Callers that
/// arrive while a release is already in progress await this instead of returning early, so that a completed
/// <see cref="BackgroundAgentsProvider.ReleaseSessionAsync"/> always means the cleanup is done. It is completed
/// successfully even when the releasing caller fails, because a waiter should observe that cleanup finished
/// rather than inherit another caller's failure.
/// </remarks>
[JsonIgnore]
public TaskCompletionSource<bool>? ReleaseCompletion { get; set; }
}
@@ -35,6 +35,11 @@ namespace Microsoft.Agents.AI;
/// </list>
/// </para>
/// <para>
/// Background tasks are tracked per session and keep running until they complete. When a host is finished with a
/// session it should call <see cref="ReleaseSessionAsync"/> to cancel and await any in-flight tasks, so that
/// abandoned work does not continue to invoke models and tools in the background.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> The agents passed to the constructor are delegated
/// arbitrary work by the parent agent — the parent sends them text input (which may include content
/// derived from the parent's own untrusted context) and receives back whatever text they produce. A
@@ -60,7 +65,16 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
{background_agents}
""";
private const string ReleasedRuntimeStartError =
"Error: The background agents runtime for this session has been released. No new background tasks can be started.";
private const string ReleasedRuntimeContinueError =
"Error: The background agents runtime for this session has been released. Background tasks can no longer be continued.";
private const string ReleasedTaskCanceledMessage = "Task was canceled because the session was released.";
private readonly Dictionary<string, AIAgent> _agents;
private static readonly TimeSpan s_defaultReleaseTimeout = TimeSpan.FromSeconds(30);
private readonly ProviderSessionState<BackgroundAgentState> _sessionState;
private readonly ProviderSessionState<BackgroundAgentRuntimeState> _runtimeSessionState;
private readonly string _instructions;
@@ -148,6 +162,248 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
return incomplete;
}
/// <summary>
/// Releases all runtime state held for the specified session, cancelling and awaiting any in-flight background tasks.
/// </summary>
/// <param name="session">The agent session whose background runtime should be released.</param>
/// <param name="cancelRunning">
/// <see langword="true"/> to cancel any background tasks that are still running; <see langword="false"/> to require that
/// all background tasks have already completed.
/// </param>
/// <param name="timeout">
/// The maximum amount of time to wait for cancelled tasks to finish. Defaults to 30 seconds when <see langword="null"/>.
/// Use <see cref="Timeout.InfiniteTimeSpan"/> to wait indefinitely. If the timeout elapses, the remaining tasks are
/// abandoned rather than blocking the caller.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests while waiting.</param>
/// <returns>A task that represents the asynchronous release operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="timeout"/> is negative and is not <see cref="Timeout.InfiniteTimeSpan"/>.</exception>
/// <exception cref="InvalidOperationException"><paramref name="cancelRunning"/> is <see langword="false"/> and one or more background tasks are still running.</exception>
/// <remarks>
/// <para>
/// Background tasks continue to execute — invoking models and tools — even after a host stops using the session
/// that started them. Hosts should call this method when a conversation ends, or from their own eviction policy,
/// so that abandoned work is stopped instead of running to completion with results nobody will read.
/// </para>
/// <para>
/// This method is idempotent: releasing an already-released session does nothing. Once released, the
/// <c>background_agents_start_task</c> and <c>background_agents_continue_task</c> tools refuse to run for that
/// session, and any tasks that were still running are recorded as
/// <see cref="BackgroundTaskStatus.Failed"/> so a restored session does not report phantom running work.
/// </para>
/// <para>
/// It is also safe to call concurrently. A caller that arrives while another release of the same session is
/// still in progress waits for that release to finish rather than returning early, so a completed call always
/// means the background tasks have been cancelled, awaited and cleaned up. Such a caller observes only its own
/// <paramref name="cancellationToken"/>; it neither inherits the in-progress release's failure nor is held up
/// by that caller's <paramref name="timeout"/>.
/// </para>
/// </remarks>
public async Task ReleaseSessionAsync(
AgentSession session,
bool cancelRunning = true,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(session);
TimeSpan effectiveTimeout = timeout ?? s_defaultReleaseTimeout;
if (effectiveTimeout < TimeSpan.Zero && effectiveTimeout != Timeout.InfiniteTimeSpan)
{
throw new ArgumentOutOfRangeException(
nameof(timeout),
effectiveTimeout,
"The timeout must not be negative, unless it is Timeout.InfiniteTimeSpan.");
}
BackgroundAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(session);
BackgroundAgentState state = this._sessionState.GetOrInitializeState(session);
KeyValuePair<int, Task<AgentResponse>>[] trackedTasks = [];
HashSet<int> pendingTaskIds = [];
TaskCompletionSource<bool>? releaseCompletion = null;
Task? releaseInProgress = null;
lock (runtimeState.SyncRoot)
{
if (runtimeState.IsReleased)
{
// A release is already in progress or has completed. Await it rather than returning early, so that
// a completed call always means the in-flight tasks have been cancelled, awaited and cleaned up.
releaseInProgress = runtimeState.ReleaseCompletion?.Task;
}
else
{
trackedTasks = runtimeState.InFlightTasks.ToArray();
// Snapshot which tasks were still pending before anything is cancelled. Tasks that had already
// finished keep their real outcome; only these pending ones are reported as released.
pendingTaskIds = [.. trackedTasks.Where(t => !t.Value.IsCompleted).Select(t => t.Key)];
if (!cancelRunning && pendingTaskIds.Count > 0)
{
throw new InvalidOperationException(
$"Cannot release the session because {pendingTaskIds.Count} background task(s) are still running. Pass cancelRunning: true to cancel them.");
}
// Cancel before publishing the release. If cancelling throws, the runtime is left un-released so
// that the caller can retry, rather than being flagged as released with tasks still running.
foreach (int taskId in pendingTaskIds)
{
if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts))
{
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
// The source was already disposed by a concurrent finalization; nothing to cancel.
}
}
}
// Continuations run asynchronously so that a waiting caller never resumes inline on the thread
// that is completing the release.
releaseCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
runtimeState.ReleaseCompletion = releaseCompletion;
runtimeState.IsReleased = true;
}
}
if (releaseCompletion is null)
{
if (releaseInProgress is not null)
{
await AwaitReleaseInProgressAsync(releaseInProgress, cancellationToken).ConfigureAwait(false);
}
return;
}
try
{
await WaitForTasksAsync(trackedTasks.Select(t => t.Value), effectiveTimeout, cancellationToken).ConfigureAwait(false);
}
finally
{
lock (runtimeState.SyncRoot)
{
// Finalize every tracked task that actually finished, so successful results and real failure
// reasons are preserved rather than being overwritten with a release failure.
foreach (var kvp in trackedTasks)
{
BackgroundTaskInfo? tracked = state.Tasks.FirstOrDefault(t => t.Id == kvp.Key);
if (tracked is null || tracked.Status != BackgroundTaskStatus.Running || !kvp.Value.IsCompleted)
{
continue;
}
FinalizeTask(tracked, kvp.Value, runtimeState);
if (kvp.Value.IsCanceled && pendingTaskIds.Contains(kvp.Key))
{
// Report the actual reason rather than the generic cancellation message.
tracked.ErrorText = ReleasedTaskCanceledMessage;
}
}
foreach (int taskId in runtimeState.TaskCancellations.Keys.ToArray())
{
DisposeTaskCancellation(runtimeState, taskId);
}
runtimeState.InFlightTasks.Clear();
runtimeState.BackgroundTaskSessions.Clear();
// Anything still running was abandoned (for example after the timeout elapsed).
foreach (BackgroundTaskInfo task in state.Tasks)
{
if (task.Status == BackgroundTaskStatus.Running)
{
task.Status = BackgroundTaskStatus.Failed;
task.ErrorText = ReleasedTaskCanceledMessage;
}
}
}
this._sessionState.SaveState(session, state);
this._runtimeSessionState.SaveState(session, runtimeState);
// Signalled last so that any caller awaiting this release observes fully cleaned-up state. Completed
// successfully even when this caller failed, because the cleanup above always runs.
releaseCompletion.TrySetResult(true);
}
}
/// <summary>
/// Waits for a release that another caller started to finish its cleanup, giving up if the caller's own
/// <paramref name="cancellationToken"/> is signalled.
/// </summary>
private static async Task AwaitReleaseInProgressAsync(Task releaseInProgress, CancellationToken cancellationToken)
{
if (releaseInProgress.IsCompleted || !cancellationToken.CanBeCanceled)
{
// The release completion is never faulted or cancelled, so awaiting it cannot throw.
await releaseInProgress.ConfigureAwait(false);
return;
}
// Do not let this caller be held up by the releasing caller's timeout.
var cancellation = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellation))
{
await Task.WhenAny(releaseInProgress, cancellation.Task).ConfigureAwait(false);
}
if (!releaseInProgress.IsCompleted)
{
cancellationToken.ThrowIfCancellationRequested();
}
}
/// <summary>
/// Waits for the specified tasks to finish, observing their exceptions and giving up once the timeout elapses.
/// </summary>
private static async Task WaitForTasksAsync(IEnumerable<Task> tasks, TimeSpan timeout, CancellationToken cancellationToken)
{
// Attach an observer to every task, including those that already completed, so that a fault is always
// observed. Otherwise clearing the last reference to a faulted task can surface an UnobservedTaskException.
Task[] observers = tasks.Select(t => t.ContinueWith(
static antecedent => _ = antecedent.Exception,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default)).ToArray();
// Observers never fault, so awaiting them cannot throw.
Task[] pending = observers.Where(o => !o.IsCompleted).ToArray();
if (pending.Length == 0)
{
return;
}
Task all = Task.WhenAll(pending);
if (timeout == Timeout.InfiniteTimeSpan && !cancellationToken.CanBeCanceled)
{
await all.ConfigureAwait(false);
return;
}
using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task delay = Task.Delay(timeout, delayCts.Token);
Task winner = await Task.WhenAny(all, delay).ConfigureAwait(false);
delayCts.Cancel();
if (winner != all)
{
// The wait was abandoned; the remaining tasks are left to finish on their own.
cancellationToken.ThrowIfCancellationRequested();
}
}
/// <summary>
/// Validates the agent collection and builds a case-insensitive name dictionary.
/// </summary>
@@ -204,25 +460,28 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
private void TryRefreshTaskState(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
{
bool changed = false;
foreach (BackgroundTaskInfo task in state.Tasks)
lock (runtimeState.SyncRoot)
{
if (task.Status != BackgroundTaskStatus.Running)
foreach (BackgroundTaskInfo task in state.Tasks)
{
continue;
}
if (task.Status != BackgroundTaskStatus.Running)
{
continue;
}
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = BackgroundTaskStatus.Lost;
changed = true;
continue;
}
if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task<AgentResponse>? inFlight))
{
// In-flight reference lost (e.g., after restart/deserialization).
task.Status = BackgroundTaskStatus.Lost;
changed = true;
continue;
}
if (inFlight.IsCompleted)
{
FinalizeTask(task, inFlight, runtimeState);
changed = true;
if (inFlight.IsCompleted)
{
FinalizeTask(task, inFlight, runtimeState);
changed = true;
}
}
}
@@ -235,6 +494,7 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
/// <summary>
/// Finalizes a task by extracting results from the completed Task and updating the BackgroundTaskInfo.
/// </summary>
/// <remarks>Callers must hold <see cref="BackgroundAgentRuntimeState.SyncRoot"/>.</remarks>
private static void FinalizeTask(BackgroundTaskInfo taskInfo, Task<AgentResponse> completedTask, BackgroundAgentRuntimeState runtimeState)
{
if (completedTask.Status == TaskStatus.RanToCompletion)
@@ -256,6 +516,65 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
}
runtimeState.InFlightTasks.Remove(taskInfo.Id);
DisposeTaskCancellation(runtimeState, taskInfo.Id);
}
/// <summary>
/// Removes and disposes the <see cref="CancellationTokenSource"/> tracked for the specified task, if any.
/// </summary>
/// <remarks>Callers must hold <see cref="BackgroundAgentRuntimeState.SyncRoot"/>.</remarks>
private static void DisposeTaskCancellation(BackgroundAgentRuntimeState runtimeState, int taskId)
{
if (runtimeState.TaskCancellations.TryGetValue(taskId, out CancellationTokenSource? cts))
{
runtimeState.TaskCancellations.Remove(taskId);
cts.Dispose();
}
}
/// <summary>
/// Starts a background run for the specified task, tracking the resulting task, the background agent session,
/// and a <see cref="CancellationTokenSource"/> that allows the run to be cancelled when the session is released.
/// </summary>
/// <returns>
/// <see langword="true"/> if the run was started and tracked; <see langword="false"/> if the session was
/// released before the run could be registered, in which case nothing is started.
/// </returns>
/// <remarks>
/// All references for the task are registered under a single acquisition of
/// <see cref="BackgroundAgentRuntimeState.SyncRoot"/>, so a concurrent release can never observe a partially
/// registered task, nor can a caller re-add references to a runtime that has already been released.
/// </remarks>
private static bool StartTrackedRun(BackgroundAgentRuntimeState runtimeState, int taskId, AIAgent agent, string input, AgentSession subSession)
{
lock (runtimeState.SyncRoot)
{
// Re-check under the lock: the session may have been released while the caller awaited session creation.
// Starting here would produce a task that is never tracked and therefore never cancelled.
if (runtimeState.IsReleased)
{
return false;
}
// Replace any cancellation source left over from a previous run of the same task.
DisposeTaskCancellation(runtimeState, taskId);
var cts = new CancellationTokenSource();
runtimeState.TaskCancellations[taskId] = cts;
// Registered here rather than by the caller so that the session reference cannot be re-added to a
// runtime that a concurrent release has already cleared. For a continued task this simply re-assigns
// the session the caller read from this same dictionary.
runtimeState.BackgroundTaskSessions[taskId] = subSession;
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession, cancellationToken: cts.Token), cts.Token);
return true;
}
}
private AITool[] CreateTools(BackgroundAgentState state, BackgroundAgentRuntimeState runtimeState, AgentSession? session)
@@ -270,6 +589,11 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
[Description("The request to pass to the background agent.")] string input,
[Description("A description of the task used to identify the task later.")] string description) =>
{
if (runtimeState.IsReleased)
{
return ReleasedRuntimeStartError;
}
if (!this._agents.TryGetValue(agentName, out AIAgent? agent))
{
return $"Error: No background agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}";
@@ -288,13 +612,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
// Create a dedicated session for this background task so it can be continued later.
AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false);
// Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async
// method that synchronously sets the static AsyncLocal CurrentRunContext. Without
// this isolation, the background agent's RunAsync would overwrite the outer (calling)
// agent's CurrentRunContext, corrupting all subsequent tool invocations in the
// same FICC batch.
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession));
runtimeState.BackgroundTaskSessions[taskId] = subSession;
if (!StartTrackedRun(runtimeState, taskId, agent, input, subSession))
{
// The session was released while the background session was being created.
state.Tasks.Remove(taskInfo);
this._sessionState.SaveState(session, state);
return ReleasedRuntimeStartError;
}
this._sessionState.SaveState(session, state);
return $"Background task {taskId} started on agent '{agentName}'.";
@@ -317,11 +641,14 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
// Collect in-flight tasks matching the requested IDs (including already-completed ones,
// since Task.WhenAny returns immediately for completed tasks).
var waitableTasks = new List<(int Id, Task<AgentResponse> Task)>();
foreach (int id in taskIds)
lock (runtimeState.SyncRoot)
{
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
foreach (int id in taskIds)
{
waitableTasks.Add((id, inFlight));
if (runtimeState.InFlightTasks.TryGetValue(id, out Task<AgentResponse>? inFlight))
{
waitableTasks.Add((id, inFlight));
}
}
}
@@ -351,7 +678,14 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id);
if (taskInfo is not null)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
lock (runtimeState.SyncRoot)
{
if (taskInfo.Status == BackgroundTaskStatus.Running)
{
FinalizeTask(taskInfo, completedEntry.Task, runtimeState);
}
}
this._sessionState.SaveState(session, state);
}
@@ -420,6 +754,11 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
AIFunctionFactory.Create(
(int taskId, string text) =>
{
if (runtimeState.IsReleased)
{
return ReleasedRuntimeContinueError;
}
this.TryRefreshTaskState(state, runtimeState, session);
BackgroundTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId);
@@ -443,7 +782,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
return $"Error: Agent '{taskInfo.AgentName}' is no longer available.";
}
if (!runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out AgentSession? subSession))
AgentSession? subSession;
lock (runtimeState.SyncRoot)
{
_ = runtimeState.BackgroundTaskSessions.TryGetValue(taskId, out subSession);
}
if (subSession is null)
{
return $"Error: Session for task {taskId} is no longer available.";
}
@@ -454,7 +799,13 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
taskInfo.ErrorText = null;
// Wrap in Task.Run to isolate the ExecutionContext (see StartBackgroundTask comment).
runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession));
if (!StartTrackedRun(runtimeState, taskId, agent, text, subSession))
{
taskInfo.Status = BackgroundTaskStatus.Failed;
taskInfo.ErrorText = ReleasedTaskCanceledMessage;
this._sessionState.SaveState(session, state);
return ReleasedRuntimeContinueError;
}
this._sessionState.SaveState(session, state);
return $"Task {taskId} continued with new input.";
@@ -486,8 +837,12 @@ public sealed class BackgroundAgentsProvider : AIContextProvider
state.Tasks.Remove(taskInfo);
// Clean up runtime references.
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.BackgroundTaskSessions.Remove(taskId);
lock (runtimeState.SyncRoot)
{
runtimeState.InFlightTasks.Remove(taskId);
runtimeState.BackgroundTaskSessions.Remove(taskId);
DisposeTaskCancellation(runtimeState, taskId);
}
this._sessionState.SaveState(session, state);
return $"Task {taskId} cleared.";
@@ -163,6 +163,10 @@ public sealed class LoopAgent : DelegatingAIAgent
// followed by that iteration's response messages. Unused when only the final response is returned.
List<ChatMessage> transcript = [];
// Accumulates usage across every inner invocation so the returned response reports the token cost of the
// whole run rather than only its final iteration. Aggregated even when only the last response is returned.
UsageDetails? aggregatedUsage = null;
// The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration).
IReadOnlyList<ChatMessage> currentSurfaced = [];
@@ -174,6 +178,8 @@ public sealed class LoopAgent : DelegatingAIAgent
AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false);
iteration++;
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// Record this iteration's on-behalf-of input (before the response it elicited) and the response itself.
transcript.AddRange(currentSurfaced);
transcript.AddRange(response.Messages);
@@ -189,21 +195,21 @@ public sealed class LoopAgent : DelegatingAIAgent
// Stop and surface the response when the agent is waiting for a tool approval.
if (HasPendingApprovalRequests(response))
{
return this.BuildResult(response, transcript);
return this.BuildResult(response, transcript, aggregatedUsage);
}
// Enforce the global safety cap regardless of what the evaluators want.
if (iteration >= this._maxIterations)
{
this.LogMaxIterationsReached(iteration);
return this.BuildResult(response, transcript);
return this.BuildResult(response, transcript, aggregatedUsage);
}
// Ask the evaluators whether to continue; stop when none of them request a re-invocation.
LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false);
if (!step.ShouldContinue)
{
return this.BuildResult(response, transcript);
return this.BuildResult(response, transcript, aggregatedUsage);
}
currentMessages = step.Messages;
@@ -446,27 +452,14 @@ public sealed class LoopAgent : DelegatingAIAgent
}
/// <summary>
/// Produces the non-streaming run result: either the final iteration's response (when configured) or an
/// aggregated response carrying the full transcript with the final response's metadata.
/// Produces the non-streaming run result from the final iteration's response, which carries either its own
/// messages (when configured) or the full transcript of the run. In both cases the usage reported is
/// <paramref name="aggregatedUsage"/>, covering every iteration of the run.
/// </summary>
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript)
{
if (this._nonStreamingReturnsLastResponseOnly)
{
return lastResponse;
}
return new AgentResponse(transcript)
{
AgentId = lastResponse.AgentId,
ResponseId = lastResponse.ResponseId,
CreatedAt = lastResponse.CreatedAt,
FinishReason = lastResponse.FinishReason,
Usage = lastResponse.Usage,
AdditionalProperties = lastResponse.AdditionalProperties,
ContinuationToken = lastResponse.ContinuationToken,
};
}
private AgentResponse BuildResult(AgentResponse lastResponse, List<ChatMessage> transcript, UsageDetails? aggregatedUsage)
=> this._nonStreamingReturnsLastResponseOnly
? lastResponse.ApplyAggregatedUsage(aggregatedUsage)
: lastResponse.ApplyAggregatedUsage(aggregatedUsage, transcript);
private static bool HasPendingApprovalRequests(AgentResponse response)
{
@@ -9,6 +9,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -28,6 +29,9 @@ namespace Microsoft.Agents.AI;
/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated
/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time.
/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again.
/// This one-at-a-time behavior no longer applies once the auto-approval cap
/// (<see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/>) is reached: the final inner turn is returned
/// as-is, so more than one approval request may be surfaced to the caller at once.
/// </item>
/// <item>
/// <b>Inbound (caller to agent):</b> When the caller sends an <see cref="AlwaysApproveToolApprovalResponseContent"/>,
@@ -47,9 +51,13 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public sealed class ToolApprovalAgent : DelegatingAIAgent
{
/// <summary>The default value used for <see cref="ToolApprovalAgentOptions.MaxAutoApprovalIterations"/> when none is specified.</summary>
public const int DefaultMaxAutoApprovalIterations = 40;
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly Func<ToolAutoApprovalRuleContext, ValueTask<bool>>[]? _autoApprovalRules;
private readonly int _maxAutoApprovalIterations;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
@@ -65,6 +73,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
{
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
this._maxAutoApprovalIterations = Throw.IfLessThan(
options?.MaxAutoApprovalIterations ?? DefaultMaxAutoApprovalIterations, 1);
this._sessionState = new ProviderSessionState<ToolApprovalState>(
_ => new ToolApprovalState(),
"toolApprovalState",
@@ -125,20 +135,48 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
// that are ALL auto-approved by standing rules, we immediately re-call with the
// collected approval responses injected. This avoids returning empty responses.
while (true)
//
// The loop is bounded by _maxAutoApprovalIterations. Each pass is a fresh inner
// invocation, so a per-request cap (FunctionInvokingChatClient.MaximumIterationsPerRequest)
// restarts every time and cannot bound it; without a cap here a model that keeps
// requesting an auto-approved tool bills indefinitely.
//
// Usage is accumulated across every re-invocation so the caller sees the token cost
// of the whole run, not just its final inner call.
UsageDetails? aggregatedUsage = null;
for (int iteration = 0; ; iteration++)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
if (iteration >= this._maxAutoApprovalIterations)
{
// Cap reached: take one final turn without auto-approving again, so any approval
// request it surfaces goes to the caller to decide rather than continuing the chain.
// Returning here without this call would hand back a response whose approval requests
// were already stripped — the empty response the loop exists to avoid.
var cappedResponse = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
// This turn is still part of the same run, so its usage joins the aggregate rather
// than replacing it; otherwise hitting the cap would discard every prior turn's cost.
UsageAggregator.Accumulate(ref aggregatedUsage, cappedResponse.Usage);
return cappedResponse.ApplyAggregatedUsage(aggregatedUsage);
}
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
UsageAggregator.Accumulate(ref aggregatedUsage, response.Usage);
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session, options, requestMessages).ConfigureAwait(false);
if (!allAutoApproved)
{
// Response has real content or an unapproved approval request — return to caller.
return response;
// Response has real content or an unapproved approval request — return to caller,
// reporting the usage accumulated across every turn of the run.
return response.ApplyAggregatedUsage(aggregatedUsage);
}
// All approval requests were auto-approved. Loop to re-invoke with them injected.
@@ -173,11 +211,26 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
// are auto-approved by standing rules, we immediately re-stream with the collected
// approval responses injected. This avoids returning empty streams.
while (true)
//
// Bounded by _maxAutoApprovalIterations for the same reason as the non-streaming path:
// every pass is a fresh inner invocation, so no per-request cap can bound it.
for (int iteration = 0; ; iteration++)
{
// Inject any collected approval responses as a user message ahead of the caller's messages.
var processedMessages = this.InjectCollectedResponses(callerMessages, state, session);
if (iteration >= this._maxAutoApprovalIterations)
{
// Cap reached: take one final turn without auto-approving again. Updates are yielded
// as-is, so any approval request reaches the caller to decide instead of continuing.
await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
// Stream from the inner agent. Non-approval content is yielded immediately.
// Approval requests are collected (not yielded) so we can classify the full batch.
List<ToolApprovalRequestContent> streamedApprovalRequests = [];
@@ -46,4 +46,24 @@ public class ToolApprovalAgentOptions
/// </para>
/// </remarks>
public IEnumerable<Func<ToolAutoApprovalRuleContext, ValueTask<bool>>>? AutoApprovalRules { get; set; }
/// <summary>
/// Gets or sets the safety cap on how many times the inner agent is re-invoked within a single run
/// because every surfaced approval request was auto-approved, or <see langword="null"/> to use
/// <see cref="ToolApprovalAgent.DefaultMaxAutoApprovalIterations"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each re-invocation is a fresh call to the inner agent, so a per-request cap such as
/// <c>FunctionInvokingChatClient.MaximumIterationsPerRequest</c> restarts every time and cannot bound this
/// loop. Without this cap a model that keeps requesting an auto-approved tool, drives an unbounded sequence of
/// billable model calls.
/// </para>
/// <para>
/// On reaching the cap the agent performs one final inner invocation without auto-approving again, so any
/// remaining approval request is surfaced to the caller to decide instead of being approved automatically.
/// Raise this value if you intend to allow longer auto-approval chains.
/// </para>
/// </remarks>
public int? MaxAutoApprovalIterations { get; set; }
}
@@ -9,6 +9,7 @@
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedRedaction>true</InjectSharedRedaction>
<InjectSharedUsage>true</InjectSharedUsage>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -21,6 +22,7 @@ namespace Microsoft.Agents.AI;
/// </summary>
/// <remarks>
/// Searches directories recursively (up to 2 levels deep) for SKILL.md files.
/// Symbolic links and reparse points below configured roots are not followed during skill discovery.
/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill
/// directory for files with matching extensions. Invalid resources are skipped with logged warnings.
/// Resource and script paths are checked against path traversal and symlink escape attacks.
@@ -114,7 +116,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
{
var discoveredPaths = DiscoverSkillDirectories(this._skillPaths);
var discoveredPaths = this.DiscoverSkillDirectories(this._skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
@@ -138,7 +140,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return Task.FromResult(skills as IList<AgentSkill>);
}
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
private List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
{
var discoveredPaths = new List<string>();
@@ -149,17 +151,23 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
continue;
}
SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
this.SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
}
return discoveredPaths;
}
private static void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
private void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
{
string skillFilePath = Path.Combine(directory, SkillFileName);
if (File.Exists(skillFilePath))
{
if (IsLinkOrReparsePointOrInaccessible(skillFilePath))
{
LogUnsafeSkillDiscoveryPath(this._logger, SanitizePathForLog(skillFilePath));
return;
}
// Once a SKILL.md is found, this directory is the skill root.
// Subdirectories are part of this skill and should not be treated as independent skill roots.
results.Add(Path.GetFullPath(directory));
@@ -171,9 +179,15 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return;
}
foreach (string subdirectory in Directory.EnumerateDirectories(directory))
foreach (string subdirectory in this.SafeEnumerateDirectories(directory, attributesToSkip: 0))
{
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
if (IsLinkOrReparsePointOrInaccessible(subdirectory))
{
LogUnsafeSkillDiscoveryPath(this._logger, SanitizePathForLog(subdirectory));
continue;
}
this.SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
}
}
@@ -324,8 +338,9 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
// segment) is a reparse point or cannot be inspected. The root directory is excluded —
// it's a caller-supplied trusted path, and the security boundary guards files within it,
// not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -386,7 +401,8 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow"
// is a reparse point or cannot be inspected.
// e.g. "references/secret.md" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -413,11 +429,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory, FileAttributes.ReparsePoint))
{
this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1);
}
@@ -452,8 +464,9 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase);
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
// segment) is a reparse point or cannot be inspected. The root directory is excluded —
// it's a caller-supplied trusted path, and the security boundary guards files within it,
// not the path itself.
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -501,7 +514,8 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Per-file symlink check: detects if the file (or any intermediate segment)
// is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow"
// is a reparse point or cannot be inspected.
// e.g. "scripts/run.py" → symlink to "/etc/shadow"
if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
@@ -528,11 +542,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
// Recurse into subdirectories if within depth limit
if (currentDepth < this._searchDepth)
{
#if NET
foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions))
#else
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory))
#endif
foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory, FileAttributes.ReparsePoint))
{
this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1);
}
@@ -540,7 +550,8 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Checks whether any segment in the path (relative to the directory) is a symlink.
/// Checks whether any segment in the path (relative to the directory) is a symlink,
/// reparse point, or cannot be inspected.
/// </summary>
private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath)
{
@@ -555,7 +566,7 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
currentPath = Path.Combine(currentPath, segment);
if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
if (IsLinkOrReparsePointOrInaccessible(currentPath))
{
return true;
}
@@ -564,30 +575,56 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
return false;
}
#if !NET
/// <summary>
/// Best-effort directory enumeration for target frameworks without
/// <c>EnumerationOptions.IgnoreInaccessible</c> support. Returns an empty
/// array when the caller lacks permission to read the directory contents,
/// so a single inaccessible child does not abort the entire skill scan.
/// </summary>
private string[] SafeEnumerateDirectories(string path)
private static bool IsLinkOrReparsePointOrInaccessible(string path)
{
try
{
return Directory.GetDirectories(path);
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch (UnauthorizedAccessException)
catch (Exception ex) when (IsFileSystemInspectionFailure(ex))
{
return true;
}
}
private static bool IsFileSystemInspectionFailure(Exception exception)
{
return exception is IOException or UnauthorizedAccessException or SecurityException;
}
/// <summary>
/// Best-effort directory enumeration that returns an empty array when the
/// directory cannot be inspected, so a single inaccessible child does not
/// abort the entire skill scan.
/// </summary>
private string[] SafeEnumerateDirectories(string path, FileAttributes attributesToSkip)
{
try
{
#if NET
var enumerationOptions = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = attributesToSkip,
};
return Directory.EnumerateDirectories(path, "*", enumerationOptions).ToArray();
#else
_ = attributesToSkip;
return Directory.GetDirectories(path);
#endif
}
catch (Exception ex) when (IsFileSystemInspectionFailure(ex))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path));
LogDirectoryInspectionFailed(this._logger, SanitizePathForLog(path));
}
return Array.Empty<string>();
}
}
#endif
private static string ParseYamlScalarValue(string yamlContent, Match kvMatch)
{
@@ -720,6 +757,9 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
[LoggerMessage(LogLevel.Warning, "Skipping skill discovery path '{Path}': symbolic link or reparse point detected, or path could not be inspected")]
private static partial void LogUnsafeSkillDiscoveryPath(ILogger logger, string path);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
@@ -732,10 +772,10 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' contains a symbolic link or reparse point, or could not be inspected")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symbolic link or reparse point, or could not be inspected")]
private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
@@ -744,12 +784,12 @@ public sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")]
private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' contains a symbolic link or reparse point, or could not be inspected")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symbolic link or reparse point, or could not be inspected")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")]
private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath);
[LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': directory could not be inspected")]
private static partial void LogDirectoryInspectionFailed(ILogger logger, string directoryPath);
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Extension methods for reporting usage aggregated by <see cref="UsageAggregator"/> on the response that
/// concludes a run.
/// </summary>
internal static class UsageAggregationExtensions
{
/// <summary>
/// Reports <paramref name="aggregatedUsage"/> on <paramref name="response"/> in place of the usage it
/// carries, which typically covers only the final service call of a run.
/// </summary>
/// <param name="response">The response produced by the final call of the run.</param>
/// <param name="aggregatedUsage">The usage accumulated across every call that made up the run.</param>
/// <param name="messages">
/// The messages the response should carry, or <see langword="null"/> to keep those it already has. Used
/// when a run returns a transcript spanning multiple calls.
/// </param>
/// <returns>The same <paramref name="response"/> instance, updated in place.</returns>
/// <remarks>
/// The supplied response is updated rather than copied, so that a derived response type returned by an
/// inner client (along with any state it carries) survives the aggregation. This matches how
/// <see cref="FunctionInvokingChatClient"/> reports the usage it accumulates across function-calling
/// iterations. Only the response is mutated: <paramref name="aggregatedUsage"/> is a freshly combined
/// instance, so no <see cref="UsageDetails"/> owned by an inner client is modified.
/// </remarks>
public static ChatResponse ApplyAggregatedUsage(this ChatResponse response, UsageDetails? aggregatedUsage, IList<ChatMessage>? messages = null)
{
if (messages is not null)
{
response.Messages = messages;
}
response.Usage = aggregatedUsage;
return response;
}
/// <summary>
/// Reports <paramref name="aggregatedUsage"/> on <paramref name="response"/> in place of the usage it
/// carries, which typically covers only the final invocation of a run.
/// </summary>
/// <param name="response">The response produced by the final invocation of the run.</param>
/// <param name="aggregatedUsage">The usage accumulated across every invocation that made up the run.</param>
/// <param name="messages">
/// The messages the response should carry, or <see langword="null"/> to keep those it already has. Used
/// when a run returns a transcript spanning multiple invocations.
/// </param>
/// <returns>The same <paramref name="response"/> instance, updated in place.</returns>
/// <remarks>
/// The supplied response is updated rather than copied, so that a derived response type returned by an
/// inner agent (such as <see cref="AgentResponse{T}"/>, along with any state it carries) survives the
/// aggregation. Only the response is mutated: <paramref name="aggregatedUsage"/> is a freshly combined
/// instance, so no <see cref="UsageDetails"/> owned by an inner agent is modified.
/// </remarks>
public static AgentResponse ApplyAggregatedUsage(this AgentResponse response, UsageDetails? aggregatedUsage, IList<ChatMessage>? messages = null)
{
if (messages is not null)
{
response.Messages = messages;
}
response.Usage = aggregatedUsage;
return response;
}
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Combines <see cref="UsageDetails"/> reported by the individual service or agent invocations that make up
/// a single logical run.
/// </summary>
/// <remarks>
/// Several components re-invoke an inner agent or chat client in a loop within a single run (for example
/// when auto-approving tool calls, injecting messages, or re-running an agent until an evaluator is
/// satisfied). Each inner invocation reports its own usage, and the aggregate must be surfaced to the
/// caller so that the reported token counts reflect the entire run rather than just its final step.
/// </remarks>
internal static class UsageAggregator
{
/// <summary>
/// Combines two <see cref="UsageDetails"/> instances into a new instance containing their summed values.
/// </summary>
/// <param name="current">The running aggregate, or <see langword="null"/> if nothing has been accumulated yet.</param>
/// <param name="incoming">The usage reported by the latest invocation, or <see langword="null"/> if none was reported.</param>
/// <returns>
/// A new <see cref="UsageDetails"/> containing the summed token counts and additional counts, or
/// <see langword="null"/> when both <paramref name="current"/> and <paramref name="incoming"/> are
/// <see langword="null"/>.
/// </returns>
/// <remarks>
/// Neither argument is mutated, and neither argument is ever returned by reference, since both may be
/// owned and observed by callers and <see cref="UsageDetails.Add"/> combines in place. Every
/// strongly-typed counter exposed by <see cref="UsageDetails"/> is summed, matching the set covered by
/// <see cref="UsageDetails.Add"/>, so that no provider-reported counter is lost when a combined instance
/// replaces the original. Token counts are summed in a null-aware manner: combining a
/// <see langword="null"/> count with a non-null count yields the non-null count, and combining two
/// <see langword="null"/> counts yields <see langword="null"/>. Entries in
/// <see cref="UsageDetails.AdditionalCounts"/> are summed per key so that provider-specific counters
/// (such as cached, reasoning, or cost counters) aggregate correctly.
/// </remarks>
public static UsageDetails? Combine(UsageDetails? current, UsageDetails? incoming)
{
if (current is null && incoming is null)
{
return null;
}
var combined = new UsageDetails
{
InputTokenCount = AddCounts(current?.InputTokenCount, incoming?.InputTokenCount),
OutputTokenCount = AddCounts(current?.OutputTokenCount, incoming?.OutputTokenCount),
TotalTokenCount = AddCounts(current?.TotalTokenCount, incoming?.TotalTokenCount),
CachedInputTokenCount = AddCounts(current?.CachedInputTokenCount, incoming?.CachedInputTokenCount),
ReasoningTokenCount = AddCounts(current?.ReasoningTokenCount, incoming?.ReasoningTokenCount),
InputAudioTokenCount = AddCounts(current?.InputAudioTokenCount, incoming?.InputAudioTokenCount),
InputTextTokenCount = AddCounts(current?.InputTextTokenCount, incoming?.InputTextTokenCount),
OutputAudioTokenCount = AddCounts(current?.OutputAudioTokenCount, incoming?.OutputAudioTokenCount),
OutputTextTokenCount = AddCounts(current?.OutputTextTokenCount, incoming?.OutputTextTokenCount),
};
AdditionalPropertiesDictionary<long>? additionalCounts = CombineAdditionalCounts(current?.AdditionalCounts, incoming?.AdditionalCounts);
if (additionalCounts is not null)
{
combined.AdditionalCounts = additionalCounts;
}
return combined;
}
/// <summary>
/// Adds the <paramref name="incoming"/> usage into the running aggregate referenced by
/// <paramref name="current"/>, replacing it with a new combined instance.
/// </summary>
/// <param name="current">The running aggregate to update. May be <see langword="null"/>.</param>
/// <param name="incoming">The usage reported by the latest invocation, or <see langword="null"/> if none was reported.</param>
public static void Accumulate(ref UsageDetails? current, UsageDetails? incoming)
=> current = Combine(current, incoming);
/// <summary>
/// Adds two nullable counts, treating <see langword="null"/> as "not reported" rather than as zero so
/// that an aggregate only reports a count when at least one contributor reported one.
/// </summary>
private static long? AddCounts(long? current, long? incoming)
=> current is null ? incoming : incoming is null ? current : current + incoming;
/// <summary>
/// Produces a new dictionary containing the per-key sums of the supplied additional counts, or
/// <see langword="null"/> when neither side has any entries.
/// </summary>
private static AdditionalPropertiesDictionary<long>? CombineAdditionalCounts(
AdditionalPropertiesDictionary<long>? current,
AdditionalPropertiesDictionary<long>? incoming)
{
bool hasCurrent = current is { Count: > 0 };
bool hasIncoming = incoming is { Count: > 0 };
if (!hasCurrent && !hasIncoming)
{
return null;
}
var combined = new AdditionalPropertiesDictionary<long>();
if (hasCurrent)
{
foreach (var entry in current!)
{
combined[entry.Key] = entry.Value;
}
}
if (hasIncoming)
{
foreach (var entry in incoming!)
{
combined[entry.Key] = combined.TryGetValue(entry.Key, out long existing)
? existing + entry.Value
: entry.Value;
}
}
return combined;
}
}

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