Compare commits

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:38:17 +02:00
Eduard van ValkenburgandCopilot d1bc78108d docs: correct observability Dependencies section
- opentelemetry-sdk is no longer a hard dependency; it is lazily imported by
  create_resource(), create_metric_views(), and configure_otel_providers()
  with a clear ImportError when missing. Day-to-day instrumentation works
  with opentelemetry-api alone provided some other component configures the
  global OpenTelemetry providers (Azure Monitor, an APM agent, application
  bootstrap, etc.).
- opentelemetry-semantic-conventions-ai is no longer used anywhere in the
  source; remove it from the listed dependencies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:34:37 +02:00
Eduard van ValkenburgandCopilot c2f9544763 docs: soften disable_instrumentation() overclaim about telemetry guarantees
Replace 'no telemetry will be emitted no matter what' (which is too strong,
since callers can still pass force=True or mutate private attributes) with
language framing the disable as a user-intent contract that library and
framework code is expected to honor: the framework actively short-circuits
the public enable paths, force=True and private-attribute writes are
acknowledged as out-of-contract escape hatches that integrations should
not use on the user's behalf.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:28:15 +02:00
Eduard van ValkenburgandCopilot 6312001ecb docs: document disable_instrumentation() and force=True paths
Add a "Disabling instrumentation" section to the observability sample README
that walks through:

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 11:24:04 +02:00
Eduard van ValkenburgandCopilot 6a23dcd555 Add disable_instrumentation() with sticky user-intent semantics
Add a public disable_instrumentation() entry point so users can explicitly opt
out of Agent Framework telemetry, with a sticky-disable flag that makes the
user's intent "leading" — no framework code path (foundry's
configure_azure_monitor, configure_otel_providers, enable_instrumentation,
enable_sensitive_telemetry, or direct OBSERVABILITY_SETTINGS.enable_*
writes) can re-enable instrumentation until the user explicitly clears the
disable with enable_instrumentation(force=True) /
enable_sensitive_telemetry(force=True).

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 09:30:41 +02:00
Tao Chen 1d1e58e12a Fix mypy 2026-05-15 10:41:18 -07:00
Tao Chen 5e5c6976f1 Formatting 2026-05-14 17:13:33 -07:00
Tao Chen 94ce49dcf5 Merge branch 'main' into local-branch-python-enable-observability-by-default 2026-05-14 17:06:28 -07:00
Tao Chen 9007b3262a Add warning 2026-05-14 17:05:59 -07:00
Tao Chen f5f0d828ab Revert uv.lock 2026-05-14 17:01:58 -07:00
Tao Chen 13b8e68503 Address Copilot comments 2026-05-14 16:56:29 -07:00
Tao Chen 07ea764469 Optimization when span is not recording 2026-05-14 16:27:58 -07:00
68357b0250 Python: Fix A2A v1.0 non-streaming response and sample runtime issues (#5849)
- Fix non-streaming empty response by accumulating intermediate WORKING
  status updates and flushing them when an empty terminal event arrives
- Fix sample agent_executor.py to enqueue Task before status events
  (required by v1.0 ActiveTask validation)
- Fix create_jsonrpc_routes() calls to include required rpc_url param
- Fix TYPE_CHECKING imports in sample agent_definitions.py
- Add tests for non-streaming content accumulation behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 22:28:02 +00:00
Tao Chen 4a83d92c96 Update samples 2026-05-14 15:02:15 -07:00
Yufeng HeandGitHub 410268b624 Python: forward MCP tool call metadata (#5815)
* Python: forward MCP tool call metadata

* fix: preserve MCP tool meta after prompt reload
2026-05-14 21:50:39 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
67f3db6280 Python: Reject path-traversal context ids in Foundry Hosting Checkpoint Storage (#5851)
* Reject path-traversal context ids in foundry workflow checkpoint storage

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/fca3aae6-50eb-4726-8baf-2718217d4e79

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Address PR review feedback: clarify URL-decode comment, isolate test root, add e2e workflow rejection tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/832f45a6-c01e-4da9-bf85-1ba7b5f302e6

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Clarify MSRC repro padding length in regression test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/832f45a6-c01e-4da9-bf85-1ba7b5f302e6

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* add E2E http test for checkpoint context id rejection

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/730258ef-2781-4a7d-b7cf-b5c40c11defc

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 21:38:37 +00:00
Tao Chen 0219e17be2 Enable instrumentation by default 2026-05-14 13:42:00 -07:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
2ef20cd0aa .NET: Add Magentic E2E workflow coverage (#5833)
* Add E2E test plan for Magentic orchestrator

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/96d76349-1ffd-482b-a3ee-ed208778b1bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add MagenticOrchestrationTests.cs scaffold for Magentic E2E tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/44a4fd8a-3828-40e5-9435-90381aeffdb8

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix MagenticOrchestrator output declaration and add first E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add plan review test and event emission tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add next speaker validation test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/322c9e2d-59bc-42ad-9a1e-f6fd4c866b26

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b2c60ce7-4d05-4a0d-b05d-d4284f5b7bb3

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add PlanSignoff_Disabled_Proceeds_Immediately E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add NextSpeaker_Empty_Falls_Back_To_First E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Task_Completes_After_Multiple_Rounds E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add PlanReview_Revised_Triggers_Replan E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add MaxRoundLimit_Terminates_Workflow E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add MaxStallCount_Triggers_Reset E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update MagenticE2E_ImplementationReview.md with full coverage status

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6e8bca46-448d-4f21-a7e9-240179571970

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1f878ef4-61b0-410a-a8bc-ebf618b3e5de

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add MaxResetLimit_Terminates_Workflow E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add PlanReview_On_Stall_Replan E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Instruction_Message_Sent_When_Present E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update ImplementationReview.md to reflect 14 tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/aba19507-7c7e-40dd-850d-d1fabb5dfa65

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/6fe88a80-2e05-40d5-9539-ca7c59b9022b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add ProgressLedger_Retry_On_Parse_Failure E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add ProgressLedger_Max_Retries_Triggers_Reset E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Stall_NoProgress_Increments_StallCount E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add PlanReview_Multiple_Revisions E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update ImplementationReview.md to reflect 18 tests and new coverage

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/125f6628-6b3b-4c51-9a51-ae84baece6bb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/21f3b1ae-183e-4fea-99ad-14efc19f084d

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Preserve IsStalled on stall-triggered plan review requests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1b9e74e8-69e1-43f2-8467-c5ba963c2622

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rename isStalled parameter to replanAfterStall for clarity

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/1b9e74e8-69e1-43f2-8467-c5ba963c2622

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Task_Delegates_To_Correct_Agent E2E test with multi-participant routing assertion

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Progress_Made_Decrements_StallCount E2E test verifying stall count decrement avoids reset

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add Consecutive_Stalls_Trigger_Reset E2E test for multi-stall threshold reset

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Magentic E2E: preserve IsStalled on stall-triggered plan reviews, add routing/stall tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9b34e409-61b8-4650-ae55-34efad034ed0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix replan-on-every-turn: skip plan on agent return; align StallCount to > (match Python)

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/43e46b0d-4263-4353-856a-c3730abb1734

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update implementation review doc for replan-fix and stall threshold alignment

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/43e46b0d-4263-4353-856a-c3730abb1734

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite Magentic E2E implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3d15763b-3a68-488e-9412-3fa280e083c0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update stall docs to use > semantics, skip checkpoint-state tests, simplify NextSpeaker fallback test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/cc9ea5a8-84d8-4b6d-bb60-ac9619824d81

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite Magentic implementation review

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ed87670a-bf4d-4ba5-a2f3-395a2eead9de

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add empty-team validation to MagenticWorkflowBuilder.Build() and E2E test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add IsTerminated guard to TakeTurnAsync and post-termination rejection test

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Rewrite ImplementationReview.md with final 23-test status

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e490fdf7-f107-4fde-ba1f-efdfd9a729c6

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add PR description markdown

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/df9b4579-10c3-4bfb-927e-da3a0e70009e

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Remove temporary markdown files

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b3e67553-a3a3-4282-98f2-afd8ad7a6b5d

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Fix IDE1006: add Async suffix to async test methods in MagenticOrchestrationTests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/629fcc07-865e-4832-9e59-ea13df561c5a

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Update error messages per review comments in MagenticOrchestrator and MagenticWorkflowBuilder

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/053e5ded-81e3-4e56-acf1-2a8a939a04b0

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Escape JSON string values in CreateProgressLedgerResponse test helper

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/ec610c61-0a14-44e2-82fd-1cf35e85d6cc

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 19:53:07 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>lokitothJacob Alber
27671974c2 .NET: Re-enable ObservabilityTests and WorkflowRunActivityStopTests (#5837)
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/220699b9-7f9e-4d5d-87d0-fb621d169d84

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 19:17:26 +00:00
7432105ebe Python: Support list[str] arguments for file-based skill scripts (#5850)
Port of .NET PR #5475. Broadens the args type from dict[str, Any] | None
to dict[str, Any] | list[str] | None across the skill script API surface,
enabling CLI-style argv forwarding to subprocess scripts.

Changes:
- SkillScript.run(), InlineSkillScript.run(), FileSkillScript.run(): widen
  args type; InlineSkillScript rejects list with TypeError
- FileSkillScript.parameters_schema: returns array-of-strings schema
- FileSkill.content: appends <scripts> block with parameters_schema
- SkillScriptRunner protocol: widen args type
- SkillsProvider._run_skill_script: widen args type
- run_skill_script tool schema: accept object, array, or null
- subprocess_script_runner sample: accept list[str], reject dict
- class_based_skill sample: fix missing SkillFrontmatter wrapper
- Standardize 'folder' to 'directory' in docstrings (#5712)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 17:58:10 +00:00
3256550c55 .NET: fix: allow naming handoff workflows (#5799)
* fix: allow naming handoff workflows

* Only set name/description if not NullOrWhitespace

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

---------

Co-authored-by: Jacob Alber <jalber@fernir.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 17:10:27 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
190ca75b6a .NET: Add Workflow Builder Specialized Edge tests (#5826)
* Add workflow builder edge tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3c3d5324-cdcd-4a38-8c67-94e4e78e29c5

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Strengthen workflow edge helper tests

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Normalize edge helper bad input validation

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Clarify edge helper target validation

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Use explicit target parameter names

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Document workflow edge test helpers

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Clarify null element validation messages

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add repeated chain executor coverage

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Preserve Throw helper validation style

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Cover empty switch case targets

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Relax builder null assertion parameter checks

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Inline ValidateTargets into call sites

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/cb9a6a6a-02c7-41a8-a4b4-da16ad62ef86

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Refactor ForwardExcept with TFM-specialized TryGetNonEnumeratedCount

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b081f61f-93ce-45dc-abbd-82c465395470

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Use TFM-specialized count check: TryGetNonEnumeratedCount for NET6+, ICollection pattern for NETFX

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8ec28a43-e7b7-456e-8d8e-921511b4accc

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Apply TFM-specialized count check to ForwardMessage as well

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9238ea32-a3e8-4b83-9683-484ad400071f

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Address review feedback: simplify Throw.IfNull in SwitchBuilder per westey-m suggestion

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/299950fd-4457-47f3-a373-f65d601b7ea5

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Use indexed parameter name in SwitchBuilder Throw.IfNull: executors[index]

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Revert #if NET6_0_OR_GREATER back to #if NET; inline executorIndex++

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Add comment explaining unusual Throw.IfNull use for null elements inside collection

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 16:23:41 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
8058fb1c5b .NET: Fix flaky InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync (#5835)
* test: remove finite timeout in BlocksUntilSignaledAsync to fix race

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/962b7404-4266-4a16-906c-ba3e607c2764

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* address review: clarify comment, add timeout test, cross-reference test names

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e406a5f2-ad31-4d37-b090-69e10713f885

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2026-05-14 15:36:42 +00:00
Peter IbekweandGitHub 189e64bfdd .NET: Add sample for invoking Foundry Toolbox tools from declarative workflows (#5829)
* Add sample for invoking Foundry Toolbox tools from declarative workflows

* Addressed initial PR comments.
2026-05-14 15:30:48 +00:00
59 changed files with 4255 additions and 545 deletions
+1
View File
@@ -242,6 +242,7 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
Inputs = ["How do I use Azure OpenAI with my data?"],
InputDelayMs = 3000,
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeMcpTool",
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeFoundryToolboxMcp.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
#
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
#
# The toolbox is provisioned with TWO different tool types:
# 1. A Foundry built-in web_search tool
# 2. A Microsoft Learn MCP server (microsoft_docs)
# Both are surfaced through the same MCP-compatible toolbox endpoint.
#
# The workflow:
# 1. Accepts a documentation/web search query as input
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
# 3. Invokes the microsoft_docs_search MCP tool
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
# 5. Uses an agent to summarize and combine both result sets
#
# Example input:
# How do I use Azure OpenAI with my data?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
# Set the search query from user input.
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =System.LastMessage.Text
# List tools exposed by the Foundry toolbox MCP proxy.
- kind: InvokeMcpTool
id: list_toolbox_tools
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: tools/list
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
output:
autoSend: true
result: Local.ToolboxTools
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
- kind: InvokeMcpTool
id: search_docs_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.SearchResult
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
- kind: InvokeMcpTool
id: search_web_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
search_query: =Local.SearchQuery
output:
autoSend: true
result: Local.WebSearchResult
# Use the agent to summarize what happened and answer from the toolbox result.
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
output:
autoSend: true
messages: Local.Summary
@@ -0,0 +1,218 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
/// <summary>
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
/// </summary>
/// <remarks>
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
/// and has a Foundry agent summarize the results.
/// </remarks>
internal sealed class Program
{
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
private const string DefaultToolboxApiVersion = "v1";
private const string DefaultDocsServerLabel = "microsoft_docs";
private const string DefaultWebSearchToolName = "web_search";
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
// 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();
// Ensure sample toolbox and agent exist in Foundry
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
IConfiguration workflowConfiguration = new ConfigurationBuilder()
.AddConfiguration(configuration)
.AddInMemoryCollection(new Dictionary<string, string?>
{
[ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl,
[DocsServerLabelSetting] = docsServerLabel,
[WebSearchToolNameSetting] = webSearchToolName,
})
.Build();
await CreateAgentAsync(foundryEndpoint, configuration, credential);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
ConcurrentBag<HttpClient> createdHttpClients = [];
DefaultMcpToolHandler mcpToolHandler = new(
httpClientProvider: async (serverUrl, _) =>
{
await Task.CompletedTask.ConfigureAwait(false);
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
{
return null;
}
FoundryToolboxBearerTokenHandler handler = new(credential)
{
InnerHandler = new HttpClientHandler()
};
HttpClient httpClient = new(handler);
createdHttpClients.Add(httpClient);
return httpClient;
});
try
{
// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
{
Configuration = workflowConfiguration,
McpToolHandler = mcpToolHandler
};
// Execute the workflow
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
finally
{
// Clean up connections and dispose created HttpClients
await mcpToolHandler.DisposeAsync();
foreach (HttpClient httpClient in createdHttpClients)
{
httpClient.Dispose();
}
}
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
await aiProjectClient.CreateAgentAsync(
agentName: "FoundryToolboxMcpAgent",
agentDefinition: DefineToolboxAgent(configuration),
agentDescription: "Summarizes Foundry toolbox MCP tool results");
}
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
{
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
{
Instructions =
"""
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
Be concise.
"""
};
}
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
{
AgentAdministrationClientOptions options = new();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
try
{
await toolboxClient.DeleteToolboxAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Toolbox does not exist.
}
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: serverLabel,
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [webTool, mcpTool],
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
}
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
{
private static readonly TokenRequestContext s_tokenContext =
new(["https://ai.azure.com/.default"]);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
private const string FeatureHeader = "Foundry-Features";
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -3,12 +3,15 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
/// <summary>
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
/// to the MCP protocol <c>tools/list</c> discovery operation.
/// </summary>
public const string ListToolsToolName = "tools/list";
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
@@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
if (IsListToolsToolName(toolName))
{
ThrowIfListToolsArgumentsSpecified(arguments);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
}
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
? null
@@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return resultContent;
}
internal static bool IsListToolsToolName(string toolName) =>
string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable<Tool> tools)
{
Throw.IfNull(tools);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString())
{
Outputs = []
};
resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools)));
return resultContent;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
@@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return hashCode.ToString(CultureInfo.InvariantCulture);
}
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
{
if (arguments is { Count: > 0 })
{
throw new ArgumentException(
$"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.",
nameof(arguments));
}
}
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
{
// Ensure Outputs list is initialized
@@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
{
return block.Resource switch
{
TextResourceContents text => new TextContent(text.Text),
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
@@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
}
private static string SerializeToolsList(IEnumerable<Tool> tools)
{
using MemoryStream stream = new();
using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions))
{
writer.WriteStartObject();
writer.WriteStartArray("tools");
foreach (Tool tool in tools)
{
writer.WriteStartObject();
writer.WriteString("name", tool.Name);
writer.WriteString("description", tool.Description);
writer.WritePropertyName("inputSchema");
tool.InputSchema.WriteTo(writer);
writer.WritePropertyName("outputSchema");
if (tool.OutputSchema is JsonElement outputSchema)
{
outputSchema.WriteTo(writer);
}
else
{
writer.WriteNullValue();
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
}
}
@@ -54,6 +54,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private string? _name;
private string? _description;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -97,6 +99,20 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public TBuilder WithName(string name)
{
this._name = name;
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public TBuilder WithDescription(string description)
{
this._description = description;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
@@ -330,7 +346,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Build the workflow.
if (!string.IsNullOrWhiteSpace(this._name))
{
builder.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
builder.WithDescription(this._description);
}
return builder.WithOutputFrom(end).Build();
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
@@ -140,7 +141,15 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
public Workflow Build()
{
if (this._team.Count == 0)
{
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
}
return this.ReduceToWorkflowBuilder().Build();
}
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
@@ -101,6 +101,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
return base.ConfigureProtocol(protocolBuilder)
.SendsMessage<ChatMessage>()
.SendsMessage<ResetChatSignal>()
.YieldsOutput<List<ChatMessage>>()
.ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
@@ -109,7 +110,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
out this._planReviewPort);
}
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext, bool replanAfterStall = false)
{
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
if (progressLedger?.IsStarted is not true)
@@ -117,7 +118,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
progressLedger = null;
}
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, replanAfterStall);
return this._planReviewPort!.PostRequestAsync(request);
}
@@ -146,7 +147,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (this._taskContext.IsTerminated)
{
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (response.IsApproved)
@@ -161,7 +162,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
}
}
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken, bool replanAfterStall = false)
{
bool isReplan = taskContext.TaskLedger != null;
@@ -177,7 +178,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (requirePlanSignoff)
{
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
await this.SubmitPlanReviewRequestAsync(taskContext, context, replanAfterStall).ConfigureAwait(false);
}
else
{
@@ -187,9 +188,22 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
// First Turn: Initialize the task context and send the initial messages to the planner agent
this._taskContext ??= new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
if (this._taskContext?.IsTerminated == true)
{
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (this._taskContext == null)
{
// First Turn: Initialize the task context and create the initial plan
this._taskContext = new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
else
{
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
private ChatMessage? _fullTaskLedgerMessage;
@@ -288,10 +302,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
bool wasStalled = taskContext.IsStalled;
taskContext.Reset();
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
}
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
@@ -58,7 +58,7 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
public bool IsTerminated { get; internal set; }
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
public bool IsStalled => this.TaskCounters.StallCount > this.TaskLimits.MaxStallCount;
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
{
@@ -36,9 +36,13 @@ public sealed class SwitchBuilder
Throw.IfNull(executors);
HashSet<int> indicies = [];
int executorIndex = 0;
foreach (ExecutorBinding executor in executors)
{
// Explicit name: null element inside the collection argument.
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
index = this._executors.Count;
@@ -64,8 +68,13 @@ public sealed class SwitchBuilder
{
Throw.IfNull(executors);
int executorIndex = 0;
foreach (ExecutorBinding executor in executors)
{
// Explicit name: null element inside the collection argument.
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
index = this._executors.Count;
@@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions
/// <param name="target">The target executor to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardMessage<TMessage>(source, [target], condition: null);
{
Throw.IfNull(target, nameof(target));
return builder.ForwardMessage<TMessage>(source, [target], condition: null);
}
/// <summary>
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
@@ -52,6 +56,8 @@ public static class WorkflowBuilderExtensions
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<TMessage, bool>? condition = null)
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(IsAllowedTypeAndMatchingCondition)!;
@@ -62,14 +68,16 @@ public static class WorkflowBuilderExtensions
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, targets.First(), predicate);
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
// The reason we can check for "not null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message));
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
}
/// <summary>
@@ -81,7 +89,11 @@ public static class WorkflowBuilderExtensions
/// <param name="target">The target executor to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardExcept<TMessage>(source, [target]);
{
Throw.IfNull(target, nameof(target));
return builder.ForwardExcept<TMessage>(source, [target]);
}
/// <summary>
/// Adds edges from the specified source to the provided executors, excluding messages of a specified type.
@@ -93,6 +105,8 @@ public static class WorkflowBuilderExtensions
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
@@ -103,14 +117,16 @@ public static class WorkflowBuilderExtensions
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, targets.First(), predicate);
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
// The reason we can check for "null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
static bool IsAllowedType(object? message) => message is null;
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
}
/// <summary>
@@ -129,6 +145,7 @@ public static class WorkflowBuilderExtensions
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(executors);
HashSet<string> seenExecutors = [source.Id];
@@ -103,6 +103,30 @@ public class HostApplicationBuilderWorkflowExtensionsTests
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
}
/// <summary>
/// Verifies that a handoff workflow can be named from the DI workflow key.
/// </summary>
[Fact]
public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "handoffWorkflow";
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("handoffAgent");
#pragma warning disable MAAIW001 // This test covers hosting handoff workflows.
builder.AddWorkflow(WorkflowName, (sp, key) =>
AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object)
.WithName(key)
.Build());
#pragma warning restore MAAIW001
var workflow = builder.Build().Services.GetRequiredKeyedService<Workflow>(WorkflowName);
Assert.Equal(WorkflowName, workflow.Name);
}
/// <summary>
/// Verifies that AddWorkflow handles empty strings for name.
/// </summary>
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests
#endregion
#region Reserved Tools/List Tests
[Fact]
public void IsListToolsToolName_WithReservedName_ShouldReturnTrue()
{
// Act
bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName);
// Assert
result.Should().BeTrue();
}
[Fact]
public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse()
{
// Act
bool result = DefaultMcpToolHandler.IsListToolsToolName("search");
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync()
{
// Arrange
DefaultMcpToolHandler handler = new();
try
{
// Act
Func<Task> act = async () => await handler.InvokeToolAsync(
serverUrl: "http://localhost:12345/mcp",
serverLabel: "test",
toolName: DefaultMcpToolHandler.ListToolsToolName,
arguments: new Dictionary<string, object?> { ["ignored"] = true },
headers: null,
connectionName: null);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.WithMessage("*does not accept tool arguments*");
}
finally
{
await handler.DisposeAsync();
}
}
[Fact]
public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync()
{
// Arrange
JsonElement inputSchema = JsonSerializer.Deserialize<JsonElement>(
"""
{
"type": "object",
"properties": {
"query": {
"type": "string"
}
},
"required": [ "query" ]
}
""");
Tool tool = new()
{
Name = "search",
Description = "Searches documentation.",
InputSchema = inputSchema
};
// Act
McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]);
// Assert
TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType<TextContent>().Subject;
using JsonDocument document = JsonDocument.Parse(text.Text);
JsonElement listedTool = document.RootElement.GetProperty("tools")[0];
listedTool.GetProperty("name").GetString().Should().Be("search");
listedTool.GetProperty("description").GetString().Should().Be("Searches documentation.");
listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string");
}
#endregion
#region Interface Implementation Tests
[Fact]
@@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests
dataContent.MediaType.Should().Be("audio/*");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent()
{
// Arrange
EmbeddedResourceBlock block = new()
{
Resource = new TextResourceContents
{
Text = "embedded text payload",
Uri = "resource://example",
MimeType = "text/plain",
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("embedded text payload");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
{
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = "application/zip",
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/zip");
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
{
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = null!,
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/octet-stream");
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
}
#endregion
}
@@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
VerifyInvocationEvent(events);
}
[Fact]
public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ListToolsToolName = "tools/list";
string? capturedToolName = null;
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync),
serverUrl: TestServerUrl,
toolName: ListToolsToolName);
Mock<IMcpToolHandler> mockProvider = new();
mockProvider.Setup(provider => provider.InvokeToolAsync(
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<IDictionary<string, string>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
.ReturnsAsync(new McpServerToolResultContent("list-tools-call-id")
{
Outputs = [new TextContent("{\"tools\":[]}")]
});
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
Assert.Equal(ListToolsToolName, capturedToolName);
}
[Fact]
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
{
@@ -86,6 +86,23 @@ public class HandoffOrchestrationTests
target.Reason.Should().Be("instructions");
}
[Fact]
public void BuildHandoffs_WithNameAndDescription_SetsWorkflowMetadata()
{
const string WorkflowName = "handoff-workflow";
const string WorkflowDescription = "A handoff workflow";
DoubleEchoAgent agent = new("agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent)
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Equal(WorkflowDescription, workflow.Description);
}
[Fact]
public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync()
{
@@ -34,7 +34,13 @@ public sealed class InputWaiterTests : IDisposable
[Fact]
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
{
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5));
// Use the no-timeout overload so that the wait can only be released by SignalInput.
// A finite timeout would make this test's logic racy: the component correctly
// honors the timeout, but if the test thread is starved of CPU time (CI load,
// GC pause) long enough for the timeout to fire, waitTask completes before
// SignalInput is called and the "should not complete before signaled" assertion
// flakes. Timeout behavior is covered separately below.
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100));
completedBeforeSignal.Should().NotBeSameAs(
@@ -100,6 +106,21 @@ public sealed class InputWaiterTests : IDisposable
this._waiter.SignalInput();
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync()
{
// Verify that a finite timeout releases the block even without a signal.
// We only assert that it *does* complete (within a generous outer bound);
// we intentionally do not assert that it stays blocked until the timeout,
// because that would re-introduce the same wall-clock flakiness
// described in BlocksUntilSignaledAsync (see comment on that test).
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300));
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires");
await waitTask;
}
}
public class OutputFilterTests
@@ -133,31 +133,31 @@ public sealed class ObservabilityTests : IDisposable
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Default");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
{
// Arrange
@@ -182,7 +182,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().ContainKey(Tags.WorkflowDefinition);
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task TelemetryDisabledByDefault_CreatesNoActivitiesAsync()
{
// Arrange
@@ -200,7 +200,7 @@ public sealed class ObservabilityTests : IDisposable
capturedActivities.Should().BeEmpty("No activities should be created when telemetry is disabled (default).");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WithOpenTelemetry_UsesProvidedActivitySourceAsync()
{
// Arrange
@@ -235,7 +235,7 @@ public sealed class ObservabilityTests : IDisposable
"All activities should come from the user-provided ActivitySource.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableWorkflowBuild_PreventsWorkflowBuildActivityAsync()
{
// Arrange
@@ -255,7 +255,7 @@ public sealed class ObservabilityTests : IDisposable
"WorkflowBuild activity should be disabled.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableWorkflowRun_PreventsWorkflowRunActivityAsync()
{
// Arrange
@@ -285,7 +285,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableExecutorProcess_PreventsExecutorProcessActivityAsync()
{
// Arrange
@@ -312,7 +312,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableEdgeGroupProcess_PreventsEdgeGroupProcessActivityAsync()
{
// Arrange
@@ -333,7 +333,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableMessageSend_PreventsMessageSendActivityAsync()
{
// Arrange
@@ -382,7 +382,7 @@ public sealed class ObservabilityTests : IDisposable
return builder.WithOpenTelemetry(configure: opts => opts.DisableMessageSend = true).Build();
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_LogsExecutorInputAndOutputAsync()
{
// Arrange
@@ -413,7 +413,7 @@ public sealed class ObservabilityTests : IDisposable
tags[Tags.ExecutorOutput].Should().Contain("HELLO", "Output should contain the transformed value.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_Disabled_DoesNotLogInputOutputAsync()
{
// Arrange
@@ -442,7 +442,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().NotContainKey(Tags.ExecutorOutput, "Output should NOT be logged when EnableSensitiveData is false.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_LogsMessageSendContentAsync()
{
// Arrange
@@ -474,7 +474,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should be logged.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_Disabled_DoesNotLogMessageContentAsync()
{
// Arrange
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
@@ -157,4 +158,301 @@ public partial class WorkflowBuilderSmokeTests
workflow3.Name.Should().Be("Named Only");
workflow3.Description.Should().BeNull();
}
[Fact]
public void ForwardMessage_WithSingleTarget_CreatesDirectEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardMessage<string>(source, target)
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.Direct);
edge.DirectEdgeData.Should().NotBeNull();
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
edge.DirectEdgeData.Condition.Should().NotBeNull();
edge.DirectEdgeData.Condition!("message").Should().BeTrue();
edge.DirectEdgeData.Condition!(42).Should().BeFalse();
edge.DirectEdgeData.Condition!(null).Should().BeFalse();
}
[Fact]
public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target1 = new("target1");
NoOpExecutor target2 = new("target2");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardMessage<string>(source, [target1, target2], message => message == "match")
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]);
edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty();
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty();
}
[Fact]
public void ForwardExcept_WithSingleTarget_CreatesDirectEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardExcept<string>(source, target)
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.Direct);
edge.DirectEdgeData.Should().NotBeNull();
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
edge.DirectEdgeData.Condition.Should().NotBeNull();
edge.DirectEdgeData.Condition!("message").Should().BeFalse();
edge.DirectEdgeData.Condition!(42).Should().BeTrue();
edge.DirectEdgeData.Condition!(null).Should().BeTrue();
}
[Fact]
public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target1 = new("target1");
NoOpExecutor target2 = new("target2");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardExcept<string>(source, [target1, target2])
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]);
edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty();
}
[Fact]
public void AddChain_CreatesSequentialDirectEdges()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor middle = new("middle");
NoOpExecutor end = new("end");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddChain(source, [middle, end])
.Build();
// Assert
Edge firstEdge = GetSingleEdge(workflow, source.Id);
firstEdge.Kind.Should().Be(EdgeKind.Direct);
firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id);
Edge secondEdge = GetSingleEdge(workflow, middle.Id);
secondEdge.Kind.Should().Be(EdgeKind.Direct);
secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id);
secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id);
}
[Fact]
public void AddChain_WhenExecutorRepeats_Throws()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor middle = new("middle");
// Act
Action act = () => new WorkflowBuilder(source.Id)
.AddChain(source, [middle, source]);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("executors");
}
[Fact]
public void AddExternalCall_CreatesRequestPortAndRoundTripEdges()
{
// Arrange
const string PortId = "port1";
NoOpExecutor source = new("start");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddExternalCall<string, int>(source, PortId)
.Build();
// Assert
workflow.Ports.Should().ContainKey(PortId);
workflow.Ports[PortId].Request.Should().Be(typeof(string));
workflow.Ports[PortId].Response.Should().Be(typeof(int));
workflow.ExecutorBindings.Should().ContainKey(PortId);
Edge requestEdge = GetSingleEdge(workflow, source.Id);
requestEdge.Kind.Should().Be(EdgeKind.Direct);
requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
requestEdge.DirectEdgeData.SinkId.Should().Be(PortId);
Edge responseEdge = GetSingleEdge(workflow, PortId);
responseEdge.Kind.Should().Be(EdgeKind.Direct);
responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId);
responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id);
}
[Fact]
public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor stringTarget = new("string-target");
NoOpExecutor intTarget = new("int-target");
NoOpExecutor defaultTarget = new("default-target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddSwitch(source, switchBuilder => switchBuilder
.AddCase<string>(message => message == "match", [stringTarget])
.AddCase<int>(message => message > 0, [intTarget])
.WithDefault([defaultTarget]))
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]);
edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]);
edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]);
}
[Fact]
public void ForwardMessage_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardMessage<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardMessage<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardMessage<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardMessage<string>(source, []));
}
[Fact]
public void ForwardExcept_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardExcept<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardExcept<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardExcept<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardExcept<string>(source, []));
}
[Fact]
public void AddChain_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
NoOpExecutor otherTarget = new("other-target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddChain(source, [target]));
Assert.Throws<ArgumentNullException>("source", () => builder.AddChain(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, null!));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, [target, null!]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, source]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, otherTarget, target]));
}
[Fact]
public void AddExternalCall_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddExternalCall<string, int>(source, "port"));
Assert.Throws<ArgumentNullException>("source", () => builder.AddExternalCall<string, int>(null!, "port"));
Assert.Throws<ArgumentNullException>("portId", () => builder.AddExternalCall<string, int>(source, null!));
}
[Fact]
public void AddSwitch_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { }));
Assert.Throws<ArgumentNullException>("source", () => builder.AddSwitch(null!, _ => { }));
Assert.Throws<ArgumentNullException>("configureSwitch", () => builder.AddSwitch(source, null!));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, _ => { }));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase<string>(_ => true, [])));
}
[Fact]
public void SwitchBuilder_InvalidArguments_Throw()
{
// Arrange
SwitchBuilder switchBuilder = new();
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>("predicate", () => switchBuilder.AddCase<string>(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.AddCase<string>(_ => true, null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.AddCase<string>(_ => true, [target, null!]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.WithDefault(null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.WithDefault([target, null!]));
}
/// <summary>
/// Gets the only edge emitted by the specified workflow source.
/// </summary>
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
}
@@ -67,7 +67,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never
/// disposed because yield break in async iterators does not trigger using disposal.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_LockstepAsync()
{
// Arrange
@@ -111,7 +111,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default)
/// execution environment (StreamingRunEventStream).
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_OffThreadAsync()
{
// Arrange
@@ -156,7 +156,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// (StreamingRun.WatchStreamAsync) with the OffThread execution environment.
/// This matches the exact usage pattern described in the issue.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync()
{
// Arrange
@@ -203,7 +203,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// streaming invocation, even when using the same workflow in a multi-turn pattern,
/// and that each session gets its own session activity.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync()
{
// Arrange
@@ -264,7 +264,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Verifies that all started activities (not just workflow_invoke) are properly stopped.
/// This ensures no spans are "leaked" without being exported.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync()
{
// Arrange
@@ -305,7 +305,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// be parented under the workflow session span. The run activity should
/// still nest correctly under the session.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync()
{
// Arrange
+1 -2
View File
@@ -44,7 +44,6 @@ GEMINI_MODEL=""
# Ollama
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
# Observability
ENABLE_INSTRUMENTATION=true
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
ENABLE_SENSITIVE_DATA=true
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
+1 -1
View File
@@ -72,7 +72,7 @@ def equal(arg1: str, arg2: str) -> bool:
from agent_framework import Agent, Message, tool
# Components
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
# Connectors (lazy-loaded)
from agent_framework.openai import OpenAIChatClient
+1 -1
View File
@@ -186,7 +186,7 @@ The package follows a flat import structure:
- **Components**: Import from `agent_framework.<component>`
```python
from agent_framework.observability import enable_instrumentation, configure_otel_providers
from agent_framework.observability import enable_sensitive_telemetry, configure_otel_providers
```
- **Connectors**: Import from `agent_framework.<vendor/platform>`
+1 -1
View File
@@ -42,7 +42,7 @@ request_handler = DefaultRequestHandler(
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
]
)
```
@@ -78,7 +78,7 @@ class A2AExecutor(AgentExecutor):
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
],
)
@@ -157,9 +157,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
except Exception as transport_error:
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
fallback_url = (
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
)
fallback_url = agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
if not fallback_url:
raise ValueError(
"A2A transport negotiation failed and no fallback URL is available. "
@@ -365,6 +363,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
all_updates: list[AgentResponseUpdate] = []
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
# In non-streaming mode, accumulate intermediate status content so it
# can be surfaced when the terminal event arrives (mirroring v0.3.x
# behavior where the full Task history was available at completion).
pending_updates_by_task: dict[str, list[AgentResponseUpdate]] = {}
async for item in a2a_stream:
payload_type = item.WhichOneof("payload")
if payload_type == "message":
@@ -391,27 +393,55 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
if task.status.state in TERMINAL_TASK_STATES:
streamed_artifact_ids_by_task.pop(task.id, None)
# If the terminal Task has no content, flush accumulated updates
if not updates or all(not u.contents for u in updates):
pending = pending_updates_by_task.pop(task.id, [])
for update in pending:
all_updates.append(update)
yield update
else:
pending_updates_by_task.pop(task.id, None)
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "status_update":
status_event = item.status_update
updates = self._updates_from_task_update_event(status_event)
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
elif is_terminal:
if updates:
# Terminal event with content — discard accumulated intermediates
pending_updates_by_task.pop(status_event.task_id, None)
for update in updates:
all_updates.append(update)
yield update
else:
# Terminal event with NO content — flush accumulated updates
pending = pending_updates_by_task.pop(status_event.task_id, [])
for update in pending:
all_updates.append(update)
yield update
else:
# Non-streaming intermediate: accumulate for later
if updates:
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
elif payload_type == "artifact_update":
artifact_event = item.artifact_update
updates = self._updates_from_task_update_event(artifact_event)
# Always yield artifact updates — they carry actual response
# content (files, data). Track IDs so that a subsequent
# terminal Task doesn't duplicate the same artifacts.
if updates:
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
artifact_event.artifact.artifact_id
)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
for update in updates:
all_updates.append(update)
yield update
else:
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
@@ -1570,4 +1570,102 @@ async def test_none_metadata_leaves_additional_properties_empty(
assert not response.additional_properties
async def test_non_streaming_terminal_status_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from terminal status_update events."""
completed_msg = A2AMessage(
message_id="msg-complete",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Done! Here is your answer.")],
)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
event = TaskStatusUpdateEvent(task_id="task-ts", context_id="ctx-ts", status=status)
mock_a2a_client.responses.append(StreamResponse(status_update=event))
response = await a2a_agent.run("Hello")
assert len(response.messages) == 1
assert response.messages[0].text == "Done! Here is your answer."
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
# Intermediate WORKING event with content
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Here is your answer from working state.")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event with NO content
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
completed_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# The accumulated WORKING content is flushed when terminal arrives empty
assert len(response.messages) == 1
assert response.messages[0].text == "Here is your answer from working state."
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming: if terminal event has content, intermediate content is discarded."""
# Intermediate WORKING event
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Still thinking...")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event WITH content
completed_msg = A2AMessage(
message_id="msg-final",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Final answer")],
)
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
completed_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# Terminal content supersedes accumulated intermediates
assert len(response.messages) == 1
assert response.messages[0].text == "Final answer"
async def test_non_streaming_artifact_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from artifact_update events."""
artifact = Artifact(
artifact_id="art-ns",
parts=[Part(text="Artifact content")],
)
event = TaskArtifactUpdateEvent(task_id="task-anu", context_id="ctx-anu", artifact=artifact, append=False)
mock_a2a_client.responses.append(StreamResponse(artifact_update=event))
# Terminal task with the same artifact ID — should be deduped
mock_a2a_client.add_task_response("task-anu", [{"id": "art-ns", "content": "Artifact content"}])
response = await a2a_agent.run("Hello")
# Artifact update + terminal task with same artifact ID = content emitted once from
# the artifact_update, then the duplicate from the task is filtered by streamed_artifact_ids
assert len(response.messages) == 1
assert response.messages[0].text == "Artifact content"
# endregion
@@ -5,7 +5,6 @@
from __future__ import annotations
import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
from agent_framework import (
@@ -30,11 +29,6 @@ from chatkit.types import (
WorkflowItem,
)
if sys.version_info >= (3, 11):
from typing import assert_never # type:ignore # pragma: no cover
else:
from typing_extensions import assert_never # type:ignore # pragma: no cover
logger = logging.getLogger(__name__)
@@ -528,7 +522,10 @@ class ThreadItemConverter:
# TODO(evmattso): Implement generated image handling in a future PR
return []
case _:
assert_never(item)
# Unknown ThreadItem variant (e.g. types added in newer chatkit versions).
# Skip rather than fail so we remain forward-compatible with chatkit upgrades.
logger.debug("Skipping unsupported ThreadItem of type %s", type(item).__name__)
return []
async def to_agent_input(
self,
+9 -3
View File
@@ -261,6 +261,7 @@ class MCPTool:
self.request_timeout = request_timeout
self.client = client
self._functions: list[FunctionTool] = []
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
self.is_connected: bool = False
self._tools_loaded: bool = False
self._prompts_loaded: bool = False
@@ -1026,6 +1027,7 @@ class MCPTool:
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
self._tool_call_meta_by_name.clear()
params: types.PaginatedRequestParams | None = None
while True:
@@ -1035,6 +1037,9 @@ class MCPTool:
tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr]
for tool in tool_list.tools:
if tool.meta is not None:
self._tool_call_meta_by_name[tool.name] = dict(tool.meta)
normalized_name = _normalize_mcp_name(tool.name)
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
@@ -1185,14 +1190,15 @@ class MCPTool:
}
}
# Inject OpenTelemetry trace context into MCP _meta for distributed tracing.
otel_meta = _inject_otel_into_mcp_meta()
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
tool_meta = self._tool_call_meta_by_name.get(tool_name)
meta = _inject_otel_into_mcp_meta(dict(tool_meta) if tool_meta is not None else None)
parser = self.parse_tool_results or self._parse_tool_result_from_mcp
# Try the operation, reconnecting once if the connection is closed
for attempt in range(2):
try:
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=meta) # type: ignore
if result.isError:
parsed = parser(result)
text = (
+84 -29
View File
@@ -289,13 +289,15 @@ class SkillScript(ABC):
return None
@abstractmethod
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run this script.
Args:
skill: The skill that owns this script.
args: Optional keyword arguments for the script, provided by the
agent/LLM.
args: Optional arguments for the script, provided by the
agent/LLM. May be a ``dict`` (named keyword arguments
for inline scripts) or a ``list[str]`` (positional CLI
arguments for file-based scripts).
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs``.
@@ -361,19 +363,31 @@ class InlineSkillScript(SkillScript):
self._parameters_schema_resolved = True
return self._parameters_schema
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run the script by invoking the callable in-process.
Args:
skill: The skill that owns this script.
args: Optional keyword arguments for the script, provided by the
agent/LLM.
agent/LLM. Must be a ``dict`` or ``None``; passing a
``list`` raises :class:`TypeError` because inline scripts
bind arguments by keyword name.
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs``.
Returns:
The script execution result.
Raises:
TypeError: If ``args`` is a ``list`` (array-style arguments
are only supported for file-based scripts).
"""
if isinstance(args, list):
raise TypeError(
f"Inline script '{self.name}' requires keyword arguments (dict), "
f"but received a list. Array-style arguments are only supported "
f"for file-based scripts."
)
if self._accepts_kwargs: # noqa: SIM108
result = self.function(**(args or {}), **kwargs)
else:
@@ -431,13 +445,23 @@ class FileSkillScript(SkillScript):
self.full_path = full_path
self._runner = runner
async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
@property
def parameters_schema(self) -> dict[str, Any] | None:
"""JSON Schema advertising that file scripts accept a string array.
Returns a fixed schema ``{"type": "array", "items": {"type": "string"}}``
so that the LLM knows to pass positional CLI arguments as a JSON array
of strings.
"""
return {"type": "array", "items": {"type": "string"}}
async def run(self, skill: Skill, args: dict[str, Any] | list[str] | None = None, **kwargs: Any) -> Any:
"""Run the script by delegating to the configured runner.
Args:
skill: The skill that owns this script. Must be a
:class:`FileSkill`.
args: Optional keyword arguments for the script.
args: Optional arguments for the script.
**kwargs: Additional runtime keyword arguments (unused).
Returns:
@@ -627,9 +651,7 @@ def _validate_compatibility(compatibility: str | None) -> None:
ValueError: If the value exceeds the maximum allowed length.
"""
if compatibility is not None and len(compatibility) > MAX_COMPATIBILITY_LENGTH:
raise ValueError(
f"Skill compatibility must be {MAX_COMPATIBILITY_LENGTH} characters or fewer."
)
raise ValueError(f"Skill compatibility must be {MAX_COMPATIBILITY_LENGTH} characters or fewer.")
def _build_skill_content(
@@ -709,6 +731,7 @@ class InlineSkill(Skill):
instructions="Use this skill for DB tasks.",
)
@skill.resource
def get_schema() -> str:
return "CREATE TABLE ..."
@@ -1348,6 +1371,7 @@ class FileSkill(Skill):
self.path = path
self._resources: list[SkillResource] = list(resources) if resources is not None else []
self._scripts: list[SkillScript] = list(scripts) if scripts is not None else []
self._cached_content: str | None = None
@property
def frontmatter(self) -> SkillFrontmatter:
@@ -1356,8 +1380,23 @@ class FileSkill(Skill):
@property
def content(self) -> str:
"""The skill content provided at construction time."""
return self._content
"""The skill content with appended scripts block.
When scripts are present, a ``<scripts>`` XML block is appended
to the raw SKILL.md content so that the LLM can discover each
script's ``<parameters_schema>``.
The result is cached after the first access. Adding scripts
after the first access will not be reflected.
"""
if self._cached_content is not None:
return self._cached_content
if not self._scripts:
self._cached_content = self._content
else:
script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
self._cached_content = f"{self._content}\n\n<scripts>\n{script_lines}\n</scripts>"
return self._cached_content
@property
def resources(self) -> list[SkillResource]:
@@ -1392,7 +1431,9 @@ class SkillScriptRunner(Protocol):
satisfies this protocol.
"""
def __call__(self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> Any:
def __call__(
self, skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | list[str] | None = None
) -> Any:
"""Run a skill script.
The :class:`SkillsProvider` resolves skill and script names
@@ -1402,7 +1443,7 @@ class SkillScriptRunner(Protocol):
Args:
skill: The file-based skill that owns the script.
script: The file-based script to run.
args: Optional keyword arguments for the script.
args: Optional arguments for the script.
Returns:
The result. May be any type; the framework
@@ -1982,7 +2023,7 @@ class SkillsProvider(ContextProvider):
if include_script_runner_tool:
async def _run_script(
skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any
) -> Any:
return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs)
@@ -2005,12 +2046,31 @@ class SkillsProvider(ContextProvider):
),
},
"args": {
"type": ["object", "null"],
"additionalProperties": True,
"oneOf": [
{
"type": "object",
"additionalProperties": True,
"description": (
"Named arguments as key-value pairs "
'(e.g. {"length": 24, "uppercase": true}).'
),
},
{
"type": "array",
"items": {"type": "string"},
"description": (
"Positional CLI arguments as a string array "
'(e.g. ["input.docx", "--output", "result.idx"]).'
),
},
{"type": "null"},
],
"default": None,
"description": (
"Arguments to pass to the script as key-value pairs. "
"Use parameter names as keys without leading dashes "
"Arguments to pass to the script. "
"Use an array of strings for CLI-style positional arguments "
'(e.g. ["input.docx", "--output", "result.idx"]), '
"or an object for named parameters "
'(e.g. {"length": 24, "uppercase": true}). '
"How these values are mapped to the underlying script "
"is determined by the script implementation or configured runner."
@@ -2060,7 +2120,7 @@ class SkillsProvider(ContextProvider):
skills: Sequence[Skill],
skill_name: str,
script_name: str,
args: dict[str, Any] | None = None,
args: dict[str, Any] | list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Run a named script from a skill.
@@ -2072,9 +2132,8 @@ class SkillsProvider(ContextProvider):
skills: The skills to look up the skill from.
skill_name: The name of the owning skill.
script_name: The script name to look up (case-insensitive).
args: Optional keyword arguments for the script, provided by the
agent/LLM. These are mapped to the function's declared
parameters.
args: Optional arguments for the script, provided by the
agent/LLM.
**kwargs: Runtime keyword arguments forwarded only to script
functions that accept ``**kwargs`` (e.g. arguments passed via
``agent.run(user_id="123")``).
@@ -2254,7 +2313,7 @@ class FileSkillsSource(SkillsSource):
Args:
skill_paths: One or more directory paths to search for file-based
skills. Each path may point to an individual skill folder
skills. Each path may point to an individual skill directory
(containing ``SKILL.md``) or to a parent that contains skill
subdirectories.
@@ -2462,11 +2521,7 @@ class FileSkillsSource(SkillsSource):
# Reject absolute paths (check both POSIX and Windows-style roots
# so validation is consistent regardless of the host OS)
if (
os.path.isabs(directory)
or normalized.startswith("/")
or re.match(r"^[A-Za-z]:[/\\]", directory)
):
if os.path.isabs(directory) or normalized.startswith("/") or re.match(r"^[A-Za-z]:[/\\]", directory):
logger.warning(
"Skipping directory '%s': absolute paths are not allowed.",
directory,
@@ -4,6 +4,8 @@
Commonly used exports:
- enable_instrumentation
- disable_instrumentation
- enable_sensitive_telemetry
- configure_otel_providers
- AgentTelemetryLayer
- ChatTelemetryLayer
@@ -80,7 +82,9 @@ __all__ = [
"configure_otel_providers",
"create_metric_views",
"create_resource",
"disable_instrumentation",
"enable_instrumentation",
"enable_sensitive_telemetry",
"get_meter",
"get_tracer",
]
@@ -643,8 +647,8 @@ class ObservabilitySettings:
Sensitive events should only be enabled on test and development environments.
Keyword Args:
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is False.
Can be set via environment variable ENABLE_INSTRUMENTATION.
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is True.
Can be disabled by setting environment variable ENABLE_INSTRUMENTATION=false.
enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False.
Can be set via environment variable ENABLE_SENSITIVE_DATA.
enable_console_exporters: Enable console exporters for traces, logs, and metrics.
@@ -659,12 +663,12 @@ class ObservabilitySettings:
from agent_framework import ObservabilitySettings
# Using environment variables
# Set ENABLE_INSTRUMENTATION=true
# Instrumentation is enabled by default; set ENABLE_INSTRUMENTATION=false to disable.
# Set ENABLE_CONSOLE_EXPORTERS=true
settings = ObservabilitySettings()
# Or passing parameters directly
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
settings = ObservabilitySettings(enable_console_exporters=True)
"""
def __init__(self, **kwargs: Any) -> None:
@@ -677,14 +681,74 @@ class ObservabilitySettings:
env_file_encoding=env_file_encoding,
**kwargs,
)
self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
# Sticky-disable flag, set by `disable_instrumentation()`. When True, this
# singleton refuses to be re-enabled by any subsequent assignment to the
# `enable_instrumentation` / `enable_sensitive_data` properties (including
# direct third-party writes). It can only be cleared by an explicit
# `enable_instrumentation(force=True)` / `enable_sensitive_telemetry(force=True)`
# call, which is the user re-stating their intent.
self._user_disabled: bool = False
# `enable_instrumentation` is defaulted to True if not set
instrumentation_value = data.get("enable_instrumentation")
self._enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value
self._enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
if self._enable_sensitive_data and not self._enable_instrumentation:
logger.warning(
"Sensitive data capture is enabled but instrumentation is disabled. "
"Sensitive data will not be captured. Please enable instrumentation to capture sensitive data."
)
self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
self.env_file_path = env_file_path
self.env_file_encoding = env_file_encoding
self._executed_setup = False
@property
def enable_instrumentation(self) -> bool:
"""Whether instrumentation is enabled.
Always returns False once ``disable_instrumentation()`` has been called,
regardless of the stored value, until ``enable_instrumentation(force=True)``
clears the sticky disable.
"""
if self._user_disabled:
return False
return self._enable_instrumentation
@enable_instrumentation.setter
def enable_instrumentation(self, value: bool) -> None:
if self._user_disabled and value:
# Defense in depth: a third-party (or internal) write of True is
# silently dropped while the user-disabled flag is set, so the
# sticky disable cannot be circumvented by direct attribute writes.
logger.debug(
"Ignoring enable_instrumentation=True assignment: instrumentation was explicitly disabled via "
"disable_instrumentation(). Call enable_instrumentation(force=True) to clear the disable."
)
return
self._enable_instrumentation = value
@property
def enable_sensitive_data(self) -> bool:
"""Whether sensitive-data capture is enabled.
Always returns False once ``disable_instrumentation()`` has been called.
"""
if self._user_disabled:
return False
return self._enable_sensitive_data
@enable_sensitive_data.setter
def enable_sensitive_data(self, value: bool) -> None:
if self._user_disabled and value:
logger.debug(
"Ignoring enable_sensitive_data=True assignment: instrumentation was explicitly disabled via "
"disable_instrumentation(). Call enable_sensitive_telemetry(force=True) to clear the disable."
)
return
self._enable_sensitive_data = value
@property
def ENABLED(self) -> bool:
"""Check if model diagnostics are enabled.
@@ -706,6 +770,17 @@ class ObservabilitySettings:
"""Check if the setup has been executed."""
return self._executed_setup
@property
def is_user_disabled(self) -> bool:
"""Whether ``disable_instrumentation()`` has been called and the disable is still in effect.
Integrations that perform telemetry setup as a side-effect (e.g. provisioning Azure Monitor
providers from a Foundry project's connection string) should consult this flag before doing
their setup work, so the user's explicit opt-out is respected end-to-end and not just at the
framework's span-emission boundary.
"""
return self._user_disabled
def _configure(
self,
*,
@@ -951,24 +1026,91 @@ def _read_int_env(name: str, *, default: int | None = None) -> int | None:
return default
def enable_sensitive_telemetry(*, force: bool = False) -> None:
"""Enable capture of sensitive data in telemetry for your application.
Instrumentation is enabled by default; this method exists to opt-in to capturing
sensitive event payloads (e.g., chat messages, tool arguments).
This method does not configure exporters or providers. It also ensures that
instrumentation is enabled (in case it was explicitly disabled via the
ENABLE_INSTRUMENTATION environment variable).
Keyword Args:
force: When True, clears any sticky disable previously set by
``disable_instrumentation()`` before enabling. Without it, calls are
no-ops if instrumentation has been explicitly disabled.
Warning:
Sensitive events should only be enabled on test and development environments.
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
logger.info(
"enable_sensitive_telemetry() ignored: instrumentation was explicitly disabled via "
"disable_instrumentation(). Pass force=True to re-enable."
)
return
if force:
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS.enable_instrumentation = True
OBSERVABILITY_SETTINGS.enable_sensitive_data = True
def disable_instrumentation() -> None:
"""Explicitly disable Agent Framework instrumentation for this process.
The disable is **sticky**: subsequent attempts by framework auto-setup paths,
library integrations, ``enable_instrumentation()``, ``enable_sensitive_telemetry()``,
``configure_otel_providers()``, or direct writes to
``OBSERVABILITY_SETTINGS.enable_instrumentation`` are ignored and no spans, metrics,
or logs are emitted by Agent Framework code paths.
To override the disable later, call ``enable_instrumentation(force=True)`` or
``enable_sensitive_telemetry(force=True)``. This makes the user's intent to opt out
win against framework code that would otherwise re-enable instrumentation
automatically.
Note:
Disabling does not tear down already-configured OpenTelemetry providers,
exporters, or in-flight spans; it gates future captures by Agent Framework
instrumentation only. To stop emitting telemetry from third-party
instrumentations as well, configure them separately.
"""
global OBSERVABILITY_SETTINGS
OBSERVABILITY_SETTINGS._user_disabled = True # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS._enable_instrumentation = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS._enable_sensitive_data = False # type: ignore[reportPrivateUsage]
def enable_instrumentation(
*,
enable_sensitive_data: bool | None = None,
force: bool = False,
) -> None:
"""Enable instrumentation for your application.
"""Enable instrumentation for Microsoft Agent Framework.
Calling this method implies you want to enable observability in your application.
This method does not configure exporters or providers.
It only updates the global variables that trigger the instrumentation code.
If you have already set the environment variable ENABLE_INSTRUMENTATION=true,
calling this method has no effect, unless you want to enable or disable sensitive data events.
Note that instrumentation is enabled by default, so this method is only necessary
if you need a programmatic way to enable it (e.g., if you are not sure whether the
environment variable ENABLE_INSTRUMENTATION is set to True or False and want to
ensure it is enabled).
Keyword Args:
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
the environment variable ENABLE_SENSITIVE_DATA if set. Default is None.
force: When True, clears any sticky disable previously set by
``disable_instrumentation()`` before enabling. Without it, calls are
no-ops if instrumentation has been explicitly disabled.
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage]
logger.info(
"enable_instrumentation() ignored: instrumentation was explicitly disabled via "
"disable_instrumentation(). Pass force=True to re-enable."
)
return
if force:
OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS.enable_instrumentation = True
if enable_sensitive_data is not None:
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
@@ -1008,7 +1150,7 @@ def configure_otel_providers(
Since you can only setup one provider per signal type (logs, traces, metrics),
you can choose to use this method and take the exporter and provider that we created.
Alternatively, you can setup the providers yourself, or through another library
(e.g., Azure Monitor) and just call `enable_instrumentation()` to enable instrumentation.
(e.g., Azure Monitor) and just call `enable_sensitive_telemetry()` to opt-in to sensitive data capture.
Note:
By default, the Agent Framework emits metrics with the prefixes `agent_framework`
@@ -1042,7 +1184,6 @@ def configure_otel_providers(
from agent_framework.observability import configure_otel_providers
# Using environment variables (recommended)
# Set ENABLE_INSTRUMENTATION=true
# Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
configure_otel_providers()
@@ -1087,18 +1228,25 @@ def configure_otel_providers(
.. code-block:: python
# when azure monitor is installed
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from azure.monitor.opentelemetry import configure_azure_monitor
connection_string = "InstrumentationKey=your_instrumentation_key_here;..."
configure_azure_monitor(connection_string=connection_string)
enable_instrumentation()
# Optional: opt into capturing sensitive data
enable_sensitive_telemetry()
References:
- https://opentelemetry.io/docs/languages/sdk-configuration/general/
- https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
"""
global OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS._user_disabled: # type: ignore[reportPrivateUsage]
logger.info(
"configure_otel_providers(): instrumentation was explicitly disabled via "
"disable_instrumentation(); providers and exporters will still be configured but "
"Agent Framework will emit no telemetry until enable_instrumentation(force=True) is called."
)
if env_file_path:
# Build kwargs, excluding None values
settings_kwargs: dict[str, Any] = {
@@ -1280,7 +1428,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
if stream:
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1344,6 +1492,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
and isinstance(response, ChatResponse)
and response.messages
and span.is_recording()
):
_capture_messages(
span=span,
@@ -1374,7 +1523,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
async def _get_response() -> ChatResponse:
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.REQUEST_MODEL) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1408,7 +1557,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
duration=duration,
)
_mark_inner_response_telemetry_captured(response)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
finish_reason = cast(
"FinishReason | None",
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
@@ -1552,7 +1701,7 @@ class AgentTelemetryLayer:
if stream:
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1613,6 +1762,7 @@ class AgentTelemetryLayer:
OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
and isinstance(response, AgentResponse)
and response.messages
and span.is_recording()
):
_capture_messages(
span=span,
@@ -1645,7 +1795,7 @@ class AgentTelemetryLayer:
async def _run() -> AgentResponse[Any]:
try:
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -1669,7 +1819,7 @@ class AgentTelemetryLayer:
)
_apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields)
_capture_response(span=span, attributes=response_attributes, duration=duration)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages and span.is_recording():
_capture_messages(
span=span,
provider_name=provider_name,
@@ -4194,6 +4194,55 @@ async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_e
assert meta is None
async def test_mcp_tool_call_tool_forwards_tool_list_meta():
"""call_tool echoes per-tool metadata returned by tools/list."""
from opentelemetry import trace
tool_meta = {
"tool_configuration": {
"name": "WorkIQSharePoint.readSmallBinaryFile",
"type": "foundry_toolbox",
}
}
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="WorkIQSharePoint.readSmallBinaryFile",
description="Read a binary file",
inputSchema={
"type": "object",
"properties": {"fileId": {"type": "string"}},
"required": ["fileId"],
},
_meta=tool_meta,
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
self.session.list_prompts = AsyncMock(return_value=types.ListPromptsResult(prompts=[]))
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
await server.load_prompts()
with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)):
await server.call_tool("WorkIQSharePoint.readSmallBinaryFile", fileId="file-1")
assert server.session.call_tool.call_args.kwargs["meta"] == tool_meta
async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client():
"""Test that calling get_mcp_client multiple times does not accumulate duplicate hooks."""
tool = MCPStreamableHTTPTool(
@@ -1015,11 +1015,25 @@ def test_observability_settings_is_setup_initial(monkeypatch):
assert settings.is_setup is False
# region Test enable_instrumentation function
def test_enable_sensitive_telemetry_function(monkeypatch):
"""Test enable_sensitive_telemetry function enables instrumentation."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_instrumentation_function(monkeypatch):
"""Test enable_instrumentation function enables instrumentation."""
"""Test enable_instrumentation function enables instrumentation when disabled via env."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
@@ -1032,10 +1046,12 @@ def test_enable_instrumentation_function(monkeypatch):
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
# Sensitive data should remain False when not explicitly enabled
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_with_sensitive_data(monkeypatch):
"""Test enable_instrumentation function with sensitive_data parameter."""
"""Test enable_instrumentation function with explicit sensitive_data parameter."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
@@ -1049,111 +1065,6 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch):
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
"""Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
import importlib
from unittest.mock import patch as mock_patch
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
"""Test that explicit parameters to configure_otel_providers override env vars."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Explicit False should override the env var True
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=False)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch):
"""Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var."""
import importlib
@@ -1269,6 +1180,161 @@ def test_enable_instrumentation_preserves_console_exporters_after_env_removed(mo
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
import importlib
from unittest.mock import patch as mock_patch
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
"""Test that explicit parameters to configure_otel_providers override env vars."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Explicit False should override the env var True
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=False)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_sensitive_telemetry_does_not_touch_console_exporters(monkeypatch):
"""Test enable_sensitive_telemetry does not modify enable_console_exporters (it is an exporter concern)."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
observability.enable_sensitive_telemetry()
# enable_console_exporters is not managed by enable_sensitive_telemetry;
# it is only read by configure_otel_providers.
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
def test_enable_sensitive_telemetry_does_not_clobber_console_exporters(monkeypatch):
"""Test enable_sensitive_telemetry does not reset enable_console_exporters set by prior configure call."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
]:
monkeypatch.delenv(key, raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Set console exporters via configure_otel_providers
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_console_exporters=True)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Calling enable_sensitive_telemetry should not clobber the value
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_enable_sensitive_telemetry_preserves_console_exporters_after_env_removed(monkeypatch):
"""Test enable_sensitive_telemetry preserves enable_console_exporters when env var is removed after reload."""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Remove the env var after reload
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
# enable_sensitive_telemetry should not reset the value
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
"""Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed."""
import importlib
@@ -1321,6 +1387,189 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
# region Test default-on instrumentation
def test_observability_settings_defaults_instrumentation_true(monkeypatch):
"""ENABLE_INSTRUMENTATION unset → ObservabilitySettings defaults to True."""
from agent_framework.observability import ObservabilitySettings
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
settings = ObservabilitySettings()
assert settings.enable_instrumentation is True
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
"""No-arg enable_instrumentation() re-reads ENABLE_SENSITIVE_DATA from env at call time.
Covers the fallback branch where the env var is set AFTER import (e.g. via load_dotenv()).
"""
import importlib
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
# Simulate load_dotenv() setting the env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
# region Test disable_instrumentation sticky behavior
def test_disable_instrumentation_flips_settings_off(monkeypatch):
"""disable_instrumentation() immediately turns instrumentation and sensitive data off."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is True
observability.disable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is False
assert observability.OBSERVABILITY_SETTINGS.ENABLED is False
def test_disable_instrumentation_is_sticky_against_enable_instrumentation(monkeypatch):
"""Sticky disable: enable_instrumentation() without force is a no-op after disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_is_sticky_against_enable_sensitive_telemetry(monkeypatch):
"""Sticky disable: enable_sensitive_telemetry() without force is a no-op after disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_sensitive_telemetry()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_is_sticky_against_configure_otel_providers(monkeypatch):
"""Sticky disable: configure_otel_providers() does not flip instrumentation back on."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_disable_instrumentation_intercepts_direct_attribute_writes(monkeypatch):
"""Sticky disable: direct OBSERVABILITY_SETTINGS.enable_instrumentation = True is intercepted."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.OBSERVABILITY_SETTINGS.enable_instrumentation = True
observability.OBSERVABILITY_SETTINGS.enable_sensitive_data = True
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
def test_enable_instrumentation_force_clears_disable(monkeypatch):
"""enable_instrumentation(force=True) clears the sticky disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(force=True, enable_sensitive_data=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_enable_sensitive_telemetry_force_clears_disable(monkeypatch):
"""enable_sensitive_telemetry(force=True) clears the sticky disable."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_sensitive_telemetry(force=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
def test_disable_instrumentation_persists_after_force_until_redisabled(monkeypatch):
"""After force-enable then disable again, the sticky disable is re-armed."""
import importlib
monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False)
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
observability = importlib.import_module("agent_framework.observability")
importlib.reload(observability)
observability.disable_instrumentation()
observability.enable_instrumentation(force=True)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
observability.disable_instrumentation()
observability.enable_instrumentation()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False
def test_disable_instrumentation_in_all(monkeypatch):
"""disable_instrumentation must be re-exported from the module's __all__."""
import agent_framework.observability as observability
assert "disable_instrumentation" in observability.__all__
assert callable(observability.disable_instrumentation)
# region Test _to_otel_part content types
@@ -3797,3 +4046,135 @@ async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvar
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(agent_spans) == 1
assert agent_spans[0].status.status_code == StatusCode.ERROR
# region Test heavy operations skipped when span is not recording
#
# When ``ENABLE_INSTRUMENTATION`` is on (the default) but no OpenTelemetry
# tracer provider has been configured, the global provider is the
# ``ProxyTracerProvider`` which returns non-recording spans. The telemetry
# layers gate sensitive-data serialization (``_capture_messages``) on
# ``span.is_recording()`` so that we don't pay the JSON-serialization cost
# when the span is going to be dropped anyway. The tests below verify that
# behavior by patching ``get_tracer`` to return a ``NoOpTracer``.
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_capture_messages_skipped_when_span_not_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Heavy message serialization is skipped when no provider is configured (non-streaming)."""
from opentelemetry.trace import NoOpTracer
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await client.get_response(messages=messages, options={"model": "Test"})
assert response is not None
# Sensitive-data serialization must be skipped because span.is_recording() is False.
assert mock_capture_messages.call_count == 0
# _capture_response still runs so that metric histograms continue to record.
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_streaming_capture_messages_skipped_when_span_not_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Heavy message serialization is skipped when no provider is configured (streaming)."""
from opentelemetry.trace import NoOpTracer
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
updates: list[ChatResponseUpdate] = []
stream = client.get_response(messages=messages, stream=True, options={"model": "Test"})
async for update in stream:
updates.append(update)
await stream.get_final_response()
assert len(updates) == 2
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_capture_messages_skipped_when_span_not_recording(
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Agent heavy serialization is skipped when no provider is configured (non-streaming)."""
from opentelemetry.trace import NoOpTracer
agent = mock_chat_agent()
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await agent.run("Test message")
assert response is not None
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_streaming_capture_messages_skipped_when_span_not_recording(
mock_chat_agent, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Agent heavy serialization is skipped when no provider is configured (streaming)."""
from opentelemetry.trace import NoOpTracer
agent = mock_chat_agent()
span_exporter.clear()
with (
patch("agent_framework.observability.get_tracer", return_value=NoOpTracer()),
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
updates: list[Any] = []
stream = agent.run("Test message", stream=True)
async for update in stream:
updates.append(update)
await stream.get_final_response()
assert len(updates) == 2
assert mock_capture_messages.call_count == 0
assert mock_capture_response.call_count == 1
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_capture_messages_called_when_span_recording(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Sanity check: with a real recording provider, sensitive-data capture still runs."""
client = mock_chat_client()
messages = [Message(role="user", contents=["Test"])]
span_exporter.clear()
with (
patch("agent_framework.observability._capture_messages") as mock_capture_messages,
patch("agent_framework.observability._capture_response") as mock_capture_response,
):
response = await client.get_response(messages=messages, options={"model": "Test"})
assert response is not None
# Two _capture_messages calls: one for input, one for output messages.
assert mock_capture_messages.call_count == 2
assert mock_capture_response.call_count == 1
+177 -12
View File
@@ -319,9 +319,7 @@ class TestDiscoverResourceFiles:
refs = skill_dir / "references"
refs.mkdir(parents=True)
(refs / "doc.md").write_text("content", encoding="utf-8")
resources = FileSkillsSource._discover_resource_files(
str(skill_dir), directories=("references", "references")
)
resources = FileSkillsSource._discover_resource_files(str(skill_dir), directories=("references", "references"))
assert resources == ["references/doc.md"]
def test_results_are_sorted(self, tmp_path: Path) -> None:
@@ -1675,9 +1673,7 @@ class TestValidateAndNormalizeDirectoryNames:
FileSkillsSource._validate_and_normalize_directory_names([" "])
def test_multiple_directories(self) -> None:
result = FileSkillsSource._validate_and_normalize_directory_names(
[".", "references", "assets", "scripts"]
)
result = FileSkillsSource._validate_and_normalize_directory_names([".", "references", "assets", "scripts"])
assert result == [".", "references", "assets", "scripts"]
def test_default_resource_directories(self) -> None:
@@ -3518,7 +3514,6 @@ class TestSkillsProviderFactories:
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_desc = run_tool.parameters()["properties"]["args"]["description"]
assert "without leading dashes" in args_desc
assert "script implementation or configured runner" in args_desc
async def test_require_script_approval_sets_approval_mode(self) -> None:
@@ -4744,12 +4739,16 @@ class TestCreateScriptElement:
def test_name_only(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py"/>'
assert 'name="run.py"' in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
def test_with_description(self) -> None:
s = FileSkillScript(name="run.py", description="Execute script.", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py" description="Execute script."/>'
assert 'name="run.py"' in elem
assert 'description="Execute script."' in elem
assert "<parameters_schema>" in elem
def test_xml_escapes_name(self) -> None:
s = FileSkillScript(name='script"special', full_path=f"{_ABS}/test/scripts/s.py")
@@ -4776,10 +4775,12 @@ class TestCreateScriptElement:
assert "query" in elem
assert "&quot;" not in elem
def test_no_parameters_for_file_script(self) -> None:
def test_file_script_includes_array_parameters(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert "<parameters_schema>" not in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
assert '"type": "string"' in elem
# ---------------------------------------------------------------------------
@@ -4800,7 +4801,7 @@ class TestSkillScriptParametersSchema:
def test_none_for_file_based_script(self) -> None:
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
assert script.parameters_schema is None
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
def test_no_params_function_returns_none(self) -> None:
def noop() -> None:
@@ -5407,3 +5408,167 @@ class TestInlineSkillContentCaching:
second = skill.content
assert first is second # Same object (cached)
assert "<name>test-skill</name>" in first
# ---------------------------------------------------------------------------
# Tests: Array-style (list[str]) script arguments
# ---------------------------------------------------------------------------
class TestArrayStyleScriptArgs:
"""Tests for list[str] arguments on skill scripts (port of .NET PR #5475)."""
async def test_inline_script_rejects_list_args(self) -> None:
"""InlineSkillScript.run() raises TypeError when args is a list."""
script = InlineSkillScript(name="greet", function=lambda name="world": f"hello {name}")
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="requires keyword arguments"):
await script.run(skill, args=["hello", "--name", "Alice"])
async def test_inline_script_error_message_mentions_script_name(self) -> None:
"""The TypeError message includes the script name for debugging."""
script = InlineSkillScript(name="my-script", function=lambda: None)
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="my-script"):
await script.run(skill, args=["arg1"])
async def test_file_script_passes_list_to_runner(self) -> None:
"""FileSkillScript.run() passes list[str] args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args=["input.docx", "--output", "result.idx"])
assert result == "ok"
assert captured["args"] == ["input.docx", "--output", "result.idx"]
async def test_file_script_passes_dict_to_runner(self) -> None:
"""FileSkillScript.run() still passes dict args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args={"key": "val"})
assert result == "ok"
assert captured["args"] == {"key": "val"}
async def test_file_script_passes_none_to_runner(self) -> None:
"""FileSkillScript.run() passes None args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill)
assert result == "ok"
assert captured["args"] is None
def test_file_script_parameters_schema_returns_array(self) -> None:
"""FileSkillScript.parameters_schema returns the string-array JSON schema."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
async def test_runner_protocol_accepts_list_args(self) -> None:
"""A runner accepting list[str] args satisfies the SkillScriptRunner protocol."""
captured: dict[str, Any] = {}
def my_runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
assert isinstance(my_runner, SkillScriptRunner)
skill = FileSkill(frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test")
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
result = my_runner(skill, script, args=["--flag", "value"])
assert result == "ok"
assert captured["args"] == ["--flag", "value"]
async def test_tool_schema_accepts_array_args(self) -> None:
"""The run_skill_script tool schema accepts array-style args via oneOf."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_schema = run_tool.parameters()["properties"]["args"]
assert "oneOf" in args_schema
types = [s.get("type") for s in args_schema["oneOf"]]
assert "object" in types
assert "array" in types
assert "null" in types
async def test_run_skill_script_with_list_args_via_provider(self) -> None:
"""End-to-end: list args flow through provider to file-based script runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "list_result"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="Body",
path=f"{_ABS}/test",
scripts=[script],
)
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="run.py", args=["input.docx", "--verbose"])
assert result == "list_result"
assert captured["args"] == ["input.docx", "--verbose"]
async def test_run_skill_script_inline_with_list_args_returns_error(self) -> None:
"""Inline script called with list args through provider returns error (TypeError caught)."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: "ok"))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1", args=["arg1"])
assert "Error" in result
assert "Failed to run" in result
def test_file_skill_content_includes_scripts_block(self) -> None:
"""FileSkill.content appends a <scripts> block when scripts are present."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
scripts=[script],
)
assert "<scripts>" in skill.content
assert 'name="run.py"' in skill.content
assert "<parameters_schema>" in skill.content
assert '"type": "array"' in skill.content
def test_file_skill_content_no_scripts_no_block(self) -> None:
"""FileSkill.content does not append a <scripts> block when no scripts."""
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
)
assert "<scripts>" not in skill.content
@@ -793,8 +793,22 @@ class RawFoundryAgent( # type: ignore[misc]
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from agent_framework.observability import (
OBSERVABILITY_SETTINGS,
create_metric_views,
create_resource,
enable_instrumentation,
)
from azure.core.exceptions import ResourceNotFoundError
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"FoundryAgent.configure_azure_monitor(): Skipping setup because instrumentation was "
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
"to re-enable, then re-invoke configure_azure_monitor()."
)
return
client = self.client
if not isinstance(client, RawFoundryAgentChatClient):
raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.")
@@ -817,8 +831,6 @@ class RawFoundryAgent( # type: ignore[misc]
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
@@ -271,8 +271,22 @@ class RawFoundryChatClient( # type: ignore[misc]
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
"""
from agent_framework.observability import (
OBSERVABILITY_SETTINGS,
create_metric_views,
create_resource,
enable_instrumentation,
)
from azure.core.exceptions import ResourceNotFoundError
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"FoundryChatClient.configure_azure_monitor(): Skipping setup because instrumentation was "
"explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) "
"to re-enable, then re-invoke configure_azure_monitor()."
)
return
try:
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
except ResourceNotFoundError:
@@ -291,8 +305,6 @@ class RawFoundryChatClient( # type: ignore[misc]
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
@@ -11,6 +11,7 @@ import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
from typing import Protocol, cast
from agent_framework import (
@@ -205,6 +206,47 @@ class FileBasedFunctionApprovalStorage:
return await asyncio.to_thread(self._load_sync, approval_request_id)
def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpointStorage:
"""Build a ``FileCheckpointStorage`` for ``context_id`` rooted under ``root``.
``context_id`` originates from caller-controlled fields such as
``previous_response_id`` or from server-generated fields such as
``conversation_id`` / ``response_id``. In every case it must be treated as
an untrusted single path segment: path separators, drive letters, parent
references and similar would otherwise let the resulting directory escape
the configured checkpoint root (CWE-22). The check resolves the joined
path and verifies it stays under the resolved root before any directory is
created on disk.
"""
if not isinstance(context_id, str) or not context_id:
raise RuntimeError("Invalid checkpoint context id: must be a non-empty string.")
# Reject any segment that is not a single safe path component. This covers
# POSIX/Windows separators, NUL bytes, drive letters, and all-dot segments
# (``.``, ``..``, ``...``, ...). We deliberately do not URL-decode the id
# here: the hosting layer never decodes context ids before joining them, so
# forms such as ``%2e%2e`` are accepted as literal directory names. Do NOT
# add decoding here without re-validating after the decode -- decode-then-
# join is exactly the pattern that reintroduces traversal. We also do not
# attempt to "sanitize" by stripping characters because that can introduce
# collisions between distinct ids.
if (
"/" in context_id
or "\\" in context_id
or "\x00" in context_id
# All-dot segments (``.``, ``..``, ``...``, ...) reduce to "" after stripping dots.
or context_id.strip(".") == ""
or os.path.isabs(context_id)
or os.path.splitdrive(context_id)[0]
):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
root_path = Path(root).resolve()
storage_path = (root_path / context_id).resolve()
if not storage_path.is_relative_to(root_path):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
return FileCheckpointStorage(storage_path)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
@@ -400,7 +442,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
latest_checkpoint_id: str | None = None
restore_storage: FileCheckpointStorage | None = None
if context_id is not None:
restore_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
restore_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
latest_checkpoint_id = latest_checkpoint.checkpoint_id
@@ -414,7 +456,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, write_context_id))
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
@@ -11,7 +11,7 @@ the registered _handle_create handler.
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from unittest.mock import AsyncMock, MagicMock
import httpx
@@ -20,6 +20,7 @@ from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
FileCheckpointStorage,
HistoryProvider,
Message,
RawAgent,
@@ -2652,3 +2653,241 @@ class TestFunctionApprovalRoundTrip:
# endregion
# region Checkpoint context path validation
class TestCheckpointContextPathValidation:
"""Regression tests for the path-traversal hardening of checkpoint storage.
These tests guard against CWE-22 in the workflow hosting path. The hosting
code joins caller-supplied identifiers (``previous_response_id``) and
server-generated identifiers (``conversation_id`` / ``response_id``) under
the configured checkpoint root. Without validation, traversal segments
such as ``../../escape`` or absolute paths cause directory creation
outside the intended root.
"""
@staticmethod
def _helper() -> Callable[[str, str], FileCheckpointStorage]:
from agent_framework_foundry_hosting._responses import ( # pyright: ignore[reportPrivateUsage]
_checkpoint_storage_for_context,
)
return _checkpoint_storage_for_context
def test_valid_segment_creates_storage_under_root(self, tmp_path: Any) -> None:
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
storage = helper(str(root), "resp_abc123")
assert storage.storage_path.is_dir()
assert storage.storage_path.parent == root.resolve()
@pytest.mark.parametrize(
"bad_id",
[
# Original MSRC repro: traversal embedded inside an id-shaped value.
# The 14 ``A``s pad the suffix to mimic the exact length of the
# ``api-made-dir<14-char-suffix>`` segment from the original report.
"caresp_x/../../service-data/api-made-dir" + "A" * 14,
# Variant report repros.
"../../escape",
"..",
".",
"...",
"/tmp/escape",
"/absolute/path",
"C:\\temp\\escape",
"..\\..\\escape",
"foo\\..\\bar",
"foo/bar",
"with\x00null",
"",
],
)
def test_traversal_and_separator_payloads_are_rejected(self, tmp_path: Any, bad_id: str) -> None:
helper = self._helper()
# Use a dedicated root *inside* tmp_path so we can assert that nothing
# was created anywhere under tmp_path (root, siblings, or above).
# Asserting against tmp_path.parent would be flaky under parallel test
# execution because tmp_path.parent is shared across tests.
root = tmp_path / "root"
root.mkdir()
before = sorted(p.name for p in tmp_path.iterdir())
with pytest.raises(RuntimeError):
helper(str(root), bad_id)
# No sibling/escape directory should have been created next to the root.
after = sorted(p.name for p in tmp_path.iterdir())
assert before == after, f"Unexpected filesystem artifacts created for payload {bad_id!r}"
# And nothing inside the root either.
assert list(root.iterdir()) == []
def test_non_string_context_id_is_rejected(self, tmp_path: Any) -> None:
helper = self._helper()
with pytest.raises(RuntimeError):
helper(str(tmp_path), None) # type: ignore[arg-type]
def test_url_encoded_traversal_is_treated_as_literal_segment(self, tmp_path: Any) -> None:
"""URL-encoded traversal should not decode to traversal at the filesystem layer.
The hosting layer never URL-decodes ids before using them; the helper
should accept ``%2e%2e`` as a single literal segment (no escape).
"""
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
storage = helper(str(root), "%2e%2e")
assert storage.storage_path.parent == root.resolve()
assert storage.storage_path.name == "%2e%2e"
@pytest.mark.parametrize(
"context_field,bad_id",
[
# Restore sink: caller-controlled previous_response_id.
("previous_response_id", "../../escape"),
("previous_response_id", "/tmp/escape-abs"),
("previous_response_id", "caresp_x/../../service-data/api-made-dir" + "A" * 14),
# Restore sink: server-issued conversation_id (defense in depth).
("conversation_id", "../../escape"),
# Write sink: malicious response_id (defense in depth).
("response_id", "../../escape"),
],
)
async def test_handle_inner_workflow_rejects_malicious_context_id(
self, tmp_path: Any, context_field: str, bad_id: str
) -> None:
"""End-to-end: ``_handle_inner_workflow`` must reject malicious ids on
both the restore sink (``previous_response_id`` / ``conversation_id``)
and the write sink (``response_id``) without creating any directories.
"""
from unittest.mock import patch
from agent_framework import WorkflowAgent
from azure.ai.agentserver.responses import ResponseContext
from azure.ai.agentserver.responses.models import CreateResponse
# Build a mock that satisfies isinstance(agent, WorkflowAgent) and the
# constructor's "no existing checkpointing" guard.
agent = MagicMock(spec=WorkflowAgent)
agent.id = "wf-agent"
agent.name = "wf"
agent.description = ""
agent.context_providers = []
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
# Constructor inspects WorkflowAgent.workflow internals; bypass setup
# by feeding a configured mock through a normal init.
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
# Re-root checkpoint storage at our isolated tmp_path so we can detect
# any escape attempt on the filesystem.
root = tmp_path / "root"
root.mkdir()
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
# Build a ResponseContext with the malicious id targeting the chosen sink.
kwargs: dict[str, Any] = {
"response_id": "resp_" + "a" * 48,
"mode_flags": MagicMock(),
}
if context_field == "previous_response_id":
request = CreateResponse(model="m", input="hi", previous_response_id=bad_id)
kwargs["previous_response_id"] = bad_id
elif context_field == "conversation_id":
request = CreateResponse(model="m", input="hi")
kwargs["conversation_id"] = bad_id
else: # response_id (write sink)
request = CreateResponse(model="m", input="hi")
kwargs["response_id"] = bad_id
# Avoid invoking the real input-resolution machinery, which would need
# a configured provider; we never reach the workflow run on rejection.
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[])):
context = ResponseContext(**kwargs)
before = sorted(p.name for p in tmp_path.iterdir())
with pytest.raises(RuntimeError, match="Invalid checkpoint context id"):
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
pass
after = sorted(p.name for p in tmp_path.iterdir())
assert before == after, f"Unexpected filesystem artifacts created for {context_field}={bad_id!r}"
assert list(root.iterdir()) == [], f"Checkpoint dir created inside root for {context_field}={bad_id!r}"
@pytest.mark.parametrize(
"context_field,bad_id",
[
# Restore sink: caller-controlled previous_response_id. These are
# rejected by request validation (HTTP 400) before the checkpoint
# code is reached.
("previous_response_id", "../../escape"),
("previous_response_id", "/tmp/escape-abs"),
("previous_response_id", "caresp_x/../../service-data/api-made-dir" + "A" * 14),
# Restore sink: server-issued conversation id (defense in depth).
# Reaches the checkpoint code and is rejected there, surfacing as
# an HTTP 5xx without creating any filesystem artifacts.
("conversation", "../../escape"),
("conversation", "/tmp/escape-abs"),
],
)
async def test_malicious_context_id_rejected_e2e(self, tmp_path: Any, context_field: str, bad_id: str) -> None:
"""End-to-end (ASGI-in-process): malicious context ids must be rejected
through the full HTTP pipeline, and no checkpoint directory may be
created on disk for either the validation-layer rejection
(``previous_response_id``) or the deeper checkpoint-layer rejection
(``conversation``).
The ``response_id`` write-sink is server-generated and not reachable
via the public HTTP surface, so its defense-in-depth check is covered
by the helper-level test above.
"""
from agent_framework import WorkflowAgent
# Build a mock that satisfies isinstance(agent, WorkflowAgent) and the
# constructor's "no existing checkpointing" guard.
agent = MagicMock(spec=WorkflowAgent)
agent.id = "wf-agent"
agent.name = "wf"
agent.description = ""
agent.context_providers = []
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock( # pyright: ignore[reportPrivateUsage]
return_value=False
)
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
# Re-root checkpoint storage at our isolated tmp_path so we can detect
# any escape attempt on the filesystem.
root = tmp_path / "root"
root.mkdir()
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
payload: dict[str, Any] = {"model": "m", "input": "hi"}
if context_field == "previous_response_id":
payload["previous_response_id"] = bad_id
else: # conversation
payload["conversation"] = bad_id
before = sorted(p.name for p in tmp_path.iterdir())
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/responses", json=payload)
after = sorted(p.name for p in tmp_path.iterdir())
# The request must not succeed; either request validation rejects it
# (4xx) or the checkpoint layer raises and the server returns 5xx.
# Either way, no successful response may be produced.
assert resp.status_code >= 400, (
f"Expected non-2xx for {context_field}={bad_id!r}, got {resp.status_code}: {resp.text[:200]}"
)
assert before == after, (
f"Unexpected filesystem artifacts under tmp_path for {context_field}={bad_id!r}: "
f"before={before} after={after}"
)
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
# endregion
@@ -1429,9 +1429,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
props = content.additional_properties or {}
# Local-shell variant serializes as `local_shell_call` carrying a server-issued id;
# plain function_call_output pairs by call_id and is safe under storage.
if (
props.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL
and props.get(OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY)
if props.get(
OPENAI_SHELL_OUTPUT_TYPE_KEY
) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and props.get(
OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
):
continue
new_args: dict[str, Any] = {}
@@ -4120,9 +4120,7 @@ async def test_prepare_options_with_conversation_id_strips_server_items_for_mixe
types = [item.get("type") for item in options["input"]]
assert "reasoning" not in types
assert "function_call" not in types
output_call_ids = {
item["call_id"] for item in options["input"] if item.get("type") == "function_call_output"
}
output_call_ids = {item["call_id"] for item in options["input"] if item.get("type") == "function_call_output"}
assert output_call_ids == {"call_history", "call_live"}
assert options["previous_response_id"] == "resp_prev123"
@@ -27,6 +27,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
# Agent Framework specific settings
# ==================================
# Observability is enabled by default. Set to "false" to opt out.
# ENABLE_INSTRUMENTATION=false
# Enable sensitive data logging (prompts, responses, etc.)
# WARNING: Only enable in dev/test environments
ENABLE_SENSITIVE_DATA=true
@@ -34,9 +37,6 @@ ENABLE_SENSITIVE_DATA=true
# Optional: Enable console exporters for debugging
# ENABLE_CONSOLE_EXPORTERS=true
# Optional: Enable observability (automatically enabled if env vars are set or configure_otel_providers() is called)
# ENABLE_INSTRUMENTATION=true
# OpenAI specific variables
# ==========================
OPENAI_API_KEY="..."
+187 -175
View File
@@ -1,12 +1,12 @@
# Agent Framework Observability
This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the OpenTelemetry standard.
These samples show how to send Agent Framework observability data to the Application Performance Management (APM) backend of your choice, based on the OpenTelemetry standard.
In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) and the console.
The samples target [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and the console, but any OTLP-compatible backend works.
> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. Or you can use the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
> **Quick Start**: For local development without Azure setup, use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) (runs locally via Docker), or the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio).
> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [page](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters.
> Other backends such as [Prometheus](https://prometheus.io/docs/introduction/overview/) are also supported. See the [OpenTelemetry Python exporters](https://opentelemetry.io/docs/languages/python/exporters/) page for the full list.
For more information, please refer to the following resources:
@@ -18,19 +18,15 @@ For more information, please refer to the following resources:
## What to expect
The Agent Framework Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of agent/model invocation and tool execution. This allows you to effectively monitor your AI application's performance and accurately track token consumption. It does so based on the Semantic Conventions for GenAI defined by OpenTelemetry, and the workflows emit their own spans to provide end-to-end visibility.
The Agent Framework Python SDK is **natively instrumented** to emit logs, traces, and metrics throughout agent/model invocation and tool execution, so you can monitor your AI application's performance and track token consumption. Instrumentation follows the OpenTelemetry [Semantic Conventions for GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/), and workflows emit their own spans for end-to-end visibility.
Next to what happens in the code when you run, we also make setting up observability as easy as possible. By calling a single function `configure_otel_providers()` from the `agent_framework.observability` module, you can enable telemetry for traces, logs, and metrics. The function automatically reads standard OpenTelemetry environment variables to configure exporters and providers, making it simple to get started.
### MCP trace propagation
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted/provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (for example, `toolbox = await client.get_toolbox(...)`, then passing `toolbox.tools` into `Agent(tools=...)`), because in those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process. As a result, the framework has no opportunity to inject trace context into those requests, and propagating `traceparent`/`tracestate` across that hosted-service boundary is the responsibility of the service runtime, not Agent Framework. If end-to-end distributed tracing to the downstream MCP server is required, use a client-opened MCP transport instead of a hosted connector.
Setting up observability is also easy: a single call to `configure_otel_providers()` from the `agent_framework.observability` module wires up the trace, log, and metric providers. It reads the standard OpenTelemetry environment variables to configure exporters automatically.
### Five patterns for configuring observability
We've identified multiple ways to configure observability in your application, depending on your needs:
> Setting up observability has two parts: (1) **instrumentation**, the code that generates telemetry, and (2) **exporter/provider configuration**, which decides where that telemetry is sent. Agent Framework is natively instrumented and **enabled by default**, so you only need to handle the second part.
There are five common ways to do that, depending on your needs:
**1. Standard otel environment variables, configured for you**
@@ -42,22 +38,29 @@ from agent_framework.observability import configure_otel_providers
# Reads OTEL_EXPORTER_OTLP_* environment variables automatically
configure_otel_providers()
```
Or if you just want console exporters:
```python
from agent_framework.observability import configure_otel_providers
# Enable console exporters via environment variable
configure_otel_providers(enable_console_exporters=True)
# It is also possible to set ENABLE_CONSOLE_EXPORTERS=true in environment
# variables instead of calling `configure_otel_providers()` with the parameter.
# The framework will automatically read that and set up console exporters.
```
This is the **recommended approach** for getting started.
**2. Custom Exporters**
One level more control over the exporters that are created is to do that yourself, and then pass them to `configure_otel_providers()`. We will still create the providers for you, but you can customize the exporters as needed:
For more control, construct exporters yourself and pass them to `configure_otel_providers()`. The framework still creates the providers for you:
```python
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.exporter import Compression
from agent_framework.observability import configure_otel_providers
# Create custom exporters with specific configuration
@@ -67,17 +70,17 @@ exporters = [
OTLPMetricExporter(endpoint="http://localhost:4317"),
]
# These will be added alongside any exporters from environment variables
configure_otel_providers(exporters=exporters, enable_sensitive_data=True)
# These are added alongside any exporters configured from environment variables
configure_otel_providers(exporters=exporters)
```
**3. Third party setup**
**3. Third-party setup**
A lot of third party specific otel package, have their own easy setup methods, for example Azure Monitor has `configure_azure_monitor()`. You can use those methods to setup the third party first, and then call `enable_instrumentation()` from the `agent_framework.observability` module to activate the Agent Framework telemetry code paths. In all these cases, if you already setup observability via environment variables, you don't need to call `enable_instrumentation()` as it will be enabled automatically.
Many third-party OTel packages ship their own setup helpers (for example, Azure Monitor's `configure_azure_monitor()`). You can use those directly — Agent Framework instrumentation is on by default, so no extra wiring is needed. To also capture sensitive data, call `enable_sensitive_telemetry()` from `agent_framework.observability`.
```python
from azure.monitor.opentelemetry import configure_azure_monitor
from agent_framework.observability import create_resource, enable_instrumentation
from agent_framework.observability import create_resource, enable_sensitive_telemetry
# Configure Azure Monitor first
configure_azure_monitor(
@@ -86,10 +89,10 @@ configure_azure_monitor(
enable_live_metrics=True,
)
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
For Microsoft Foundry projects, use `client.configure_azure_monitor()` which retrieves the connection string from the project and configures everything:
```python
@@ -110,7 +113,7 @@ Or with [Langfuse](https://langfuse.com/integrations/frameworks/microsoft-agent-
```python
# environment should be setup correctly, with langfuse urls and keys
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from langfuse import get_client
langfuse = get_client()
@@ -121,9 +124,9 @@ if langfuse.auth_check():
else:
print("Authentication failed. Please check your credentials and host.")
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Agent Framework instrumentation is on by default.
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agent-framework):
@@ -131,53 +134,152 @@ Or with [Comet Opik](https://www.comet.com/docs/opik/integrations/microsoft-agen
```python
import os
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
# Use Opik OTLP settings from your project settings
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "<opik_otlp_endpoint>"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "<opik_otlp_headers>"
# Then activate Agent Framework's telemetry code paths
# This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars
enable_instrumentation(enable_sensitive_data=False)
# Agent Framework instrumentation is on by default.
# Optional: opt in to capturing sensitive data
enable_sensitive_telemetry()
```
**4. Manual setup**
Of course you can also do a complete manual setup of exporters, providers, and instrumentation. Please refer to sample [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a comprehensive example of how to manually setup exporters and providers for traces, logs, and metrics that will get sent to the console. This gives you full control over which exporters and providers to use. We do have a helper function `create_resource()` in the `agent_framework.observability` module that you can use to create a resource with the appropriate service name and version based on environment variables or standard defaults for Agent Framework, this is not used in the sample.
**5. Auto-instrumentation (zero-code)**
You can also use the [OpenTelemetry CLI tool](https://opentelemetry.io/docs/instrumentation/python/getting-started/#automatic-instrumentation) to automatically instrument your application without changing any code. Please refer to sample [advanced_zero_code.py](./advanced_zero_code.py) for an example of how to use the CLI tool to enable instrumentation for Agent Framework applications.
For full control, set up providers and exporters yourself. See [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a complete example that sends traces, logs, and metrics to the console. The `create_resource()` helper in `agent_framework.observability` can build a resource with the appropriate service name and version from environment variables (or sensible defaults), although the sample does not use it.
**5. Zero-code provider/exporter configuration**
Because Agent Framework is **natively instrumented** with OpenTelemetry, you do not need to auto-instrument the framework itself. You can, however, use the [`opentelemetry-instrument`](https://opentelemetry.io/docs/zero-code/python/) CLI wrapper to configure the global tracer/meter providers and exporters from environment variables (or CLI flags) at process startup. Your application code then does not need to call `configure_otel_providers()` — the native spans and metrics from Agent Framework are picked up by the globally configured pipeline. See [advanced_zero_code.py](./advanced_zero_code.py) for an example.
### MCP trace propagation
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally configured OpenTelemetry propagator(s) — W3C Trace Context by default (producing `traceparent` and `tracestate`) — so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted or provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (e.g. `toolbox = await client.get_toolbox(...)` then `Agent(tools=toolbox.tools)`). In those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process, so propagating `traceparent`/`tracestate` across that boundary is the service runtime's responsibility. If you need end-to-end distributed tracing to the downstream MCP server, use a client-opened MCP transport instead of a hosted connector.
## Configuration
### Dependencies
As part of Agent Framework we use the following OpenTelemetry packages:
- `opentelemetry-api`
- `opentelemetry-sdk`
- `opentelemetry-semantic-conventions-ai`
Agent Framework's core depends on **`opentelemetry-api`** only — the API package is enough for the instrumentation hooks (spans, meters, log records) to emit telemetry, and it has no runtime side effects when no provider is configured.
We do not install exporters by default, so you will need to add those yourself, this prevents us from installing unnecessary dependencies. For Application Insights, you will need to install `azure-monitor-opentelemetry`. For Aspire Dashboard or other OTLP compatible backends, you will need to install `opentelemetry-exporter-otlp-proto-grpc`. For HTTP protocol support, you will also need to install `opentelemetry-exporter-otlp-proto-http`.
If you want the framework to set up providers / exporters for you via `configure_otel_providers()` (or to use the `create_resource()` / `create_metric_views()` helpers), you also need the OpenTelemetry SDK:
And for many others, different packages are used, so refer to the documentation of the specific exporter you want to use.
```bash
pip install opentelemetry-sdk
```
If `opentelemetry-sdk` is missing, those helper functions raise a clear `ImportError` telling you to install it. Day-to-day instrumentation still works without the SDK as long as some other component (e.g. `azure-monitor-opentelemetry`, your application bootstrap, an APM agent) has configured the global OpenTelemetry providers.
Exporters are **not** installed by default — install only what you need:
- **Application Insights**: `azure-monitor-opentelemetry`
- **Aspire Dashboard or other OTLP/gRPC backends**: `opentelemetry-exporter-otlp-proto-grpc`
- **OTLP over HTTP**: `opentelemetry-exporter-otlp-proto-http`
For other backends, refer to the documentation of the specific exporter.
### Environment variables
The following environment variables are used to turn on/off observability of the Agent Framework:
Agent Framework reads the following environment variables:
- `ENABLE_INSTRUMENTATION`
- `ENABLE_SENSITIVE_DATA`
- `ENABLE_CONSOLE_EXPORTERS`
| Variable | Default | Purpose |
|----------|---------|---------|
| `ENABLE_INSTRUMENTATION` | `true` | Set to `false` to disable native instrumentation. See [Disabling instrumentation](#disabling-instrumentation) for the programmatic alternative with sticky semantics. |
| `ENABLE_SENSITIVE_DATA` | `false` | Set to `true` to emit sensitive data (prompts, responses, etc.). |
| `ENABLE_CONSOLE_EXPORTERS` | `false` | Set to `true` to add console exporters. Only used by `configure_otel_providers()`. |
| `VS_CODE_EXTENSION_PORT` | unset | Port used by the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) tracing integration. Only used by `configure_otel_providers()`. |
All of these are booleans and default to `false`.
You can also call `enable_sensitive_telemetry()` from `agent_framework.observability` to opt in to sensitive-data capture programmatically.
Finally we have `VS_CODE_EXTENSION_PORT` which you can set to a port, which can be used to setup the AI Toolkit for VS Code tracing integration. See [here](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio#tracing) for more details.
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
The framework will emit observability data when the `ENABLE_INSTRUMENTATION` environment variable is set to `true`. If both are `true` then it will also emit sensitive information. When these are not set, or set to false, you can use the `enable_instrumentation()` function from the `agent_framework.observability` module to turn on instrumentation programmatically. This is useful when you want to control this via code instead of environment variables.
### Disabling instrumentation
> **Note**: Sensitive information includes prompts, responses, and more, and should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data.
There are two ways to turn Agent Framework's native instrumentation off, and they have **different scopes**:
The two other variables, `ENABLE_CONSOLE_EXPORTERS` and `VS_CODE_EXTENSION_PORT`, are used to configure where the observability data is sent. Those are only activated when calling `configure_otel_providers()`.
| Approach | Scope | Sticky? | When framework code calls `enable_instrumentation()` later, what happens? |
|----------|-------|---------|---------------------------------------------------------------------------|
| `ENABLE_INSTRUMENTATION=false` in the environment | Initial settings only | No | Instrumentation flips back **on**. |
| `disable_instrumentation()` called from code | Process-wide, sticky | Yes | Instrumentation **stays off** — the user-disable intent wins. |
If you want telemetry off **and want it to stay off**, use `disable_instrumentation()`.
#### Sticky semantics — why this matters
Framework integrations and third-party libraries can call `enable_instrumentation()`, `enable_sensitive_telemetry()`, or `configure_otel_providers()` as part of their own setup. For example, `FoundryChatClient.configure_azure_monitor()` calls `enable_instrumentation()` after wiring up Azure Monitor. That's normally what you want — but if **you** have explicitly opted out, you don't want any of those calls to silently re-enable telemetry.
`disable_instrumentation()` solves this by setting a **sticky** flag on `OBSERVABILITY_SETTINGS` that remains in effect until you explicitly clear it. While the flag is set:
1. `OBSERVABILITY_SETTINGS.enable_instrumentation` and `enable_sensitive_data` **read as `False`** regardless of the stored value.
2. `enable_instrumentation()` and `enable_sensitive_telemetry()` are **no-ops** and log an info-level message.
3. `configure_otel_providers()` still configures providers / exporters / views (so a later force-enable can use them), but does not flip instrumentation on.
4. Direct attribute writes like `OBSERVABILITY_SETTINGS.enable_instrumentation = True` from any code are **silently dropped** (defense in depth).
5. Integrations that consult `OBSERVABILITY_SETTINGS.is_user_disabled` (e.g. `FoundryChatClient.configure_azure_monitor()`, `FoundryAgent.configure_azure_monitor()`) **skip their setup entirely**, so global Azure Monitor providers aren't installed unnecessarily.
```python
from agent_framework.observability import disable_instrumentation
# After this call, Agent Framework expresses your intent to opt out of telemetry.
# Library and framework code is expected to honor that intent and not flip
# instrumentation back on (e.g. by calling `enable_instrumentation()`,
# `enable_sensitive_telemetry()`, or writing to public attributes on
# `OBSERVABILITY_SETTINGS`). The framework actively short-circuits the public
# enable paths so the user's intent stays leading. A determined caller can still
# pass `force=True` or mutate private (`_`-prefixed) attributes to bypass it,
# but those are out-of-contract escape hatches that should not be used by
# integrations on the user's behalf.
disable_instrumentation()
```
#### Forcing re-enablement after a disable
To intentionally re-enable telemetry after `disable_instrumentation()`, pass `force=True` to either of the two public enable helpers. This is the only way to clear the sticky disable, so the user's opt-out can only be reversed by a deliberate user opt-in:
```python
from agent_framework.observability import (
disable_instrumentation,
enable_instrumentation,
enable_sensitive_telemetry,
)
disable_instrumentation()
# Without force=True, these are no-ops while the disable is sticky:
enable_instrumentation() # logs info, does nothing
enable_sensitive_telemetry() # logs info, does nothing
# With force=True, the sticky disable is cleared and the call proceeds:
enable_instrumentation(force=True)
# or
enable_sensitive_telemetry(force=True)
# After a force-enable you can `disable_instrumentation()` again to re-arm
# the sticky disable.
```
#### Checking the disable state from integrations
If you're writing an integration that performs telemetry setup as a side effect (e.g. provisioning a third-party exporter), consult the public read-only `is_user_disabled` property and early-return when it's set:
```python
from agent_framework.observability import OBSERVABILITY_SETTINGS
if OBSERVABILITY_SETTINGS.is_user_disabled:
logger.info(
"Skipping telemetry setup because the user called disable_instrumentation()."
)
return
```
This is what the built-in `FoundryChatClient.configure_azure_monitor()` and `FoundryAgent.configure_azure_monitor()` do — so calling `disable_instrumentation()` reliably prevents Azure Monitor's global providers from being installed by those helpers.
#### What `disable_instrumentation()` does **not** do
- It does not tear down OpenTelemetry providers, exporters, or in-flight spans that were already set up before the disable call. It only gates **future** captures by Agent Framework code paths.
- It does not stop telemetry from third-party instrumentations (e.g. `azure-monitor-opentelemetry`'s system metrics) that are wired up outside Agent Framework. Configure those separately if needed.
- It does not persist across processes. Each Python process starts with the disable flag cleared; if you always want telemetry off in a given environment, set `ENABLE_INSTRUMENTATION=false` as an environment variable in addition to (or instead of) the programmatic call.
#### Environment variables for `configure_otel_providers()`
@@ -202,7 +304,8 @@ The `configure_otel_providers()` function automatically reads **standard OpenTel
> **Note**: These are standard OpenTelemetry environment variables. See the [OpenTelemetry spec](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for more details.
#### Logging
Use standard Python logging configuration to align logs with telemetry output.
Use standard Python logging configuration to align logs with telemetry output:
```python
import logging
@@ -212,15 +315,14 @@ logging.basicConfig(
datefmt="%Y-%m-%d %H:%M:%S",
)
```
You can control at what level logging happens and thus what logs get exported, you can do this, by adding this:
To control which logs are exported, adjust the root logger level — other loggers inherit from it by default:
```python
import logging
logger = logging.getLogger()
logger.setLevel(logging.NOTSET)
logging.getLogger().setLevel(logging.NOTSET)
```
This gets the root logger and sets the level of that, automatically other loggers inherit from that one, and you will get detailed logs in your telemetry.
## Samples
@@ -228,36 +330,35 @@ This folder contains different samples demonstrating how to use telemetry in var
| Sample | Description |
|--------|-------------|
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | **Recommended starting point**: Shows how to create custom exporters with specific configuration and pass them to `configure_otel_providers()`. Useful for advanced scenarios. |
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | Shows how to setup telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
| [agent_observability.py](./agent_observability.py) | Shows telemetry collection for an agentic application with tool calls using environment variables. |
| [foundry_tracing.py](./foundry_tracing.py) | Shows Azure Monitor integration with Foundry for any chat client. |
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: Shows manual setup of exporters and providers with console output. Useful for understanding how observability works under the hood. |
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: Shows zero-code telemetry setup using the `opentelemetry-enable_instrumentation` CLI tool. |
| [workflow_observability.py](./workflow_observability.py) | Shows telemetry collection for a workflow with multiple executors and message passing. |
| [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | **Recommended starting point**: configure telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). |
| [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | Create custom exporters with specific configuration and pass them to `configure_otel_providers()`. |
| [agent_observability.py](./agent_observability.py) | Telemetry collection for an agentic application with tool calls. |
| [foundry_tracing.py](./foundry_tracing.py) | Azure Monitor integration with Microsoft Foundry. |
| [workflow_observability.py](./workflow_observability.py) | Telemetry collection for a workflow with multiple executors and message passing. |
| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: manual setup of exporters and providers with console output — useful for understanding how observability works under the hood. |
| [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: zero-code provider/exporter setup using the `opentelemetry-instrument` CLI wrapper. |
### Running the samples
1. Open a terminal and navigate to this folder: `python/samples/02-agents/observability/`. This is necessary for the `.env` file to be read correctly.
2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example).
> **Note**: You can start with just `ENABLE_INSTRUMENTATION=true` and add `OTEL_EXPORTER_OTLP_ENDPOINT` or other configuration as needed. If no exporters are configured, you can set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
3. Choose one environment-loading approach:
- **A. Sample-managed loading (current samples):** run from this folder so the sample's `load_dotenv()` call can find `.env`.
- **B. Shell/IDE-managed environment:** set/export environment variables directly, or use an IDE run configuration that injects env vars / `.env`.
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")` (or your own settings loader path).
- **D. CLI-managed env file:** run with `uv` and pass the file explicitly, for example:
`uv run --env-file=.env python configure_otel_providers_with_env_var.py`
4. Activate your python virtual environment, then run a sample (for example `python configure_otel_providers_with_env_var.py`).
1. Open a terminal in this folder (`python/samples/02-agents/observability/`) so that `.env` is found.
2. Create a `.env` file if you don't already have one. See [.env.example](./.env.example).
> Instrumentation is on by default. Set `OTEL_EXPORTER_OTLP_ENDPOINT` (or other configuration) as needed. With no exporters configured, set `ENABLE_CONSOLE_EXPORTERS=true` for console output.
3. Pick an environment-loading approach:
- **A. Sample-managed:** run from this folder so the sample's `load_dotenv()` call can find `.env`.
- **B. Shell/IDE-managed:** export environment variables, or use an IDE run configuration that injects them.
- **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")`.
- **D. CLI-managed:** run with `uv` and pass the file explicitly, e.g. `uv run --env-file=.env python configure_otel_providers_with_env_var.py`.
4. Activate your virtual environment, then run a sample (e.g. `python configure_otel_providers_with_env_var.py`).
> If you do manual provider setup (e.g., Azure Monitor), call `enable_instrumentation()` to turn on Agent Framework telemetry code paths; if you want Agent Framework to configure exporters/providers for you, call `configure_otel_providers(...)`.
> If you set up providers manually (e.g. Azure Monitor), Agent Framework instrumentation is still on by default. Call `enable_sensitive_telemetry()` if you also want to capture sensitive data. To have Agent Framework configure exporters and providers for you, call `configure_otel_providers(...)`.
> Each sample will print the Operation/Trace ID, which can be used later for filtering logs and traces in Application Insights or Aspire Dashboard.
> Each sample prints its Operation/Trace ID, which you can use to filter logs and traces in Application Insights or the Aspire Dashboard.
# Appendix
## Azure Monitor Queries
When you are in Azure Monitor and want to have a overall view of the span, use this query in the logs section:
For an overall view of a span in Azure Monitor, run this query in the Logs section:
```kusto
dependencies
@@ -280,7 +381,8 @@ dependencies
```
### Grafana dashboards with Application Insights data
Besides the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
In addition to the native Application Insights UI, you can use Grafana to visualize the same telemetry data. Two tailored dashboards are available to get you started:
#### Agent Overview dashboard
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
@@ -292,117 +394,27 @@ Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
## Migration Guide
We've done a major update to the observability API in Agent Framework Python SDK. The new API simplifies configuration by relying more on standard OpenTelemetry environment variables and have split the instrumentation from the configuration.
Instrumentation is now **enabled by default** (you no longer have to opt in by calling `enable_instrumentation()` at startup), and the way you opt in to capturing sensitive payloads has its own dedicated function.
If you're updating from a previous version of the Agent Framework, here are the key changes to the observability API:
### Environment Variables
| Old Variable | New Variable | Notes |
|-------------|--------------|-------|
| `OTLP_ENDPOINT` | `OTEL_EXPORTER_OTLP_ENDPOINT` | Standard OpenTelemetry env var |
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | N/A | Use `configure_azure_monitor()` |
| N/A | `ENABLE_CONSOLE_EXPORTERS` | New opt-in flag for console output |
### OTLP Configuration
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
# Via parameter
setup_observability(otlp_endpoint="http://localhost:4317")
# Via environment variable
# OTLP_ENDPOINT=http://localhost:4317
setup_observability()
```
**After (Current):**
```python
from agent_framework.observability import configure_otel_providers
# Via standard OTEL environment variable (recommended)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
configure_otel_providers()
# Or via custom exporters
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
configure_otel_providers(exporters=[
OTLPSpanExporter(endpoint="http://localhost:4317"),
OTLPLogExporter(endpoint="http://localhost:4317"),
OTLPMetricExporter(endpoint="http://localhost:4317"),
])
```
### Azure Monitor Configuration
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
setup_observability(
applicationinsights_connection_string="InstrumentationKey=...",
applicationinsights_live_metrics=True,
)
```
**After (Current):**
If your code previously did:
```python
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import create_resource, enable_instrumentation
from azure.identity import AzureCliCredential
from azure.monitor.opentelemetry import configure_azure_monitor
from agent_framework.observability import enable_instrumentation
async def main():
# For Microsoft Foundry projects
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4o",
credential=AzureCliCredential(),
)
await client.configure_azure_monitor(enable_live_metrics=True)
# For non-Azure AI projects
configure_azure_monitor(
connection_string="InstrumentationKey=...",
resource=create_resource(),
enable_live_metrics=True,
)
enable_instrumentation()
enable_instrumentation(enable_sensitive_data=True)
```
### Console Output
replace it with:
**Before (Deprecated):**
```
from agent_framework.observability import setup_observability
# Console was used as automatic fallback
setup_observability() # Would output to console if no exporters configured
```
**After (Current):**
```python
from agent_framework.observability import configure_otel_providers
from agent_framework.observability import enable_sensitive_telemetry
# Console exporters are now opt-in
# ENABLE_CONSOLE_EXPORTERS=true
configure_otel_providers()
# Or programmatically
configure_otel_providers(enable_console_exporters=True)
enable_sensitive_telemetry()
```
### Benefits of New API
`enable_sensitive_telemetry()` ensures that instrumentation is on and turns sensitive-event capture on in one call. `enable_instrumentation()` still exists for the rare case where you want to programmatically force instrumentation on without enabling sensitive data (e.g. to override `ENABLE_INSTRUMENTATION=false`), and it now also accepts `force=True` to clear a previous `disable_instrumentation()` — see [Disabling instrumentation](#disabling-instrumentation).
1. **Standards Compliant**: Uses standard OpenTelemetry environment variables
2. **Simpler**: Less configuration needed, more relies on environment
3. **Flexible**: Easy to add custom exporters alongside environment-based ones
4. **Cleaner Separation**: Azure Monitor setup is in Azure-specific client
5. **Better Compatibility**: Works with any OTEL-compatible tool (Jaeger, Zipkin, Prometheus, etc.)
> **Note**: Sensitive data includes prompts, responses, and tool arguments. Only enable it in development or test environments — it may expose user or system secrets in production.
## Aspire Dashboard
@@ -437,7 +449,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
Or set it as an environment variable when running your samples:
```bash
ENABLE_INSTRUMENTATION=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python configure_otel_providers_with_env_var.py
```
### Viewing telemetry data
@@ -7,7 +7,7 @@ from typing import Annotated
from agent_framework import Message, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.observability import enable_instrumentation
from agent_framework.observability import enable_sensitive_telemetry
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from opentelemetry._logs import set_logger_provider
@@ -135,7 +135,8 @@ async def main():
setup_logging()
setup_tracing()
setup_metrics()
enable_instrumentation()
# Instrumentation is enabled by default; call this to also capture sensitive data.
enable_sensitive_telemetry()
await run_chat_client()
@@ -19,13 +19,20 @@ if TYPE_CHECKING:
"""
This sample shows how you can configure observability of an application with zero code changes.
It relies on the OpenTelemetry auto-instrumentation capabilities, and the observability setup
is done via environment variables.
Follow the install guidance from https://opentelemetry.io/docs/zero-code/python/ to install the OpenTelemetry CLI tool,
when using `uv` there are some additional steps, so follow the instructions carefully.
Agent Framework is natively instrumented with OpenTelemetry, so no auto-instrumentation of the
framework itself is required. Running the `opentelemetry-instrument` CLI wrapper simply configures
the global tracer/meter providers and exporters from environment variables (or CLI flags) at
process startup, so the application code does not need to set them up explicitly. The native
spans/metrics emitted by Agent Framework are then picked up by that globally configured pipeline.
And setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update the endpoint below).
See: https://opentelemetry.io/docs/zero-code/python/
Install the OpenTelemetry CLI tool following the guidance above (when using `uv` there are some
additional steps, so follow the instructions carefully).
Then setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update
the endpoint below).
Then you can run:
```bash
@@ -10,7 +10,7 @@ import os
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
from textwrap import dedent
from agent_framework import Agent, ClassSkill, SkillsProvider
from agent_framework import Agent, ClassSkill, SkillFrontmatter, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -49,10 +49,12 @@ class UnitConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description=(
"Convert between common units using a multiplication factor. "
"Use when asked to convert miles, kilometers, pounds, or kilograms."
frontmatter=SkillFrontmatter(
name="unit-converter",
description=(
"Convert between common units using a multiplication factor. "
"Use when asked to convert miles, kilometers, pounds, or kilograms."
),
),
)
@@ -20,30 +20,43 @@ from typing import Any
from agent_framework import FileSkill, FileSkillScript
def subprocess_script_runner(skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> str:
def subprocess_script_runner(
skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | list[str] | None = None
) -> str:
"""Run a skill script as a local Python subprocess.
Uses ``FileSkillScript.full_path`` as the script path, converts the
``args`` dict to CLI flags, and returns captured output.
``args`` to CLI arguments, and returns captured output.
Args:
skill: The file-based skill that owns the script.
script: The file-based script to run.
args: Optional arguments forwarded as CLI flags.
args: Optional arguments. A ``list[str]`` is forwarded as
positional CLI arguments. Passing a ``dict`` or any other
type raises :class:`TypeError` — file-based scripts expect
positional arguments as a JSON array of strings.
Returns:
The combined stdout/stderr output, or an error message.
Raises:
TypeError: If ``args`` is not a ``list[str]`` or ``None``, or if
any list element is not a string.
"""
script_path = Path(script.full_path)
if not script_path.is_file():
return f"Error: Script file not found: {script_path}"
cmd = [sys.executable, str(script_path)]
# Convert args dict to CLI flags
if args:
for key, value in args.items():
if isinstance(value, bool):
if value:
cmd.append(f"--{key}")
elif value is not None:
cmd.append(f"--{key}")
cmd.append(str(value))
if isinstance(args, list):
for item in args:
if not isinstance(item, str):
raise TypeError(
f"File-based skill scripts only accept string CLI arguments "
f"but received a {type(item).__name__}. "
f"All array elements must be strings."
)
cmd.extend(args)
elif args is not None:
raise TypeError(
f"Expected a list of CLI arguments but received {type(args).__name__}. "
f"File-based skill scripts expect positional arguments as a list of strings."
)
try:
result = subprocess.run(
cmd,
@@ -22,9 +22,6 @@ What this example shows:
- executor_completed events (type='executor_completed') contain the messages sent via ctx.send_message() in event.data
- How to generically observe all executor I/O through workflow streaming events
This approach allows you to enable_instrumentation any workflow for observability without
changing the executor implementations.
Prerequisites:
- No external services required.
"""
+1 -1
View File
@@ -103,7 +103,7 @@ def main() -> None:
app = Starlette(
routes=[
*create_agent_card_routes(agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
]
)
@@ -8,16 +8,11 @@ AgentCards for the invoice, policy, and logistics agent types.
from __future__ import annotations
from typing import TYPE_CHECKING
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices
if TYPE_CHECKING:
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
# ---------------------------------------------------------------------------
# Agent instructions
# ---------------------------------------------------------------------------
+20 -55
View File
@@ -10,18 +10,12 @@ published back through the a2a-sdk event queue.
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING
from a2a.helpers import new_task_from_user_message
from a2a.server.agent_execution.agent_executor import AgentExecutor
from a2a.types import (
Message,
Part,
Role,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
)
from a2a.server.tasks import TaskUpdater
from a2a.types import Part, TaskState
if TYPE_CHECKING:
from a2a.server.agent_execution.context import RequestContext
@@ -47,17 +41,17 @@ class AgentFrameworkExecutor(AgentExecutor):
if not user_text:
user_text = "Hello"
task_id = context.task_id or str(uuid.uuid4())
context_id = context.context_id or str(uuid.uuid4())
# v1.0 requires a Task object in the queue before any TaskStatusUpdateEvent
task = context.current_task
if not task and context.message:
task = new_task_from_user_message(context.message)
await event_queue.enqueue_event(task)
task_id = task.id if task else context.task_id
updater = TaskUpdater(event_queue, task_id, context.context_id)
# Signal that the agent is working
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
)
)
await updater.start_work()
try:
response = await self.agent.run(user_text)
@@ -71,48 +65,19 @@ class AgentFrameworkExecutor(AgentExecutor):
if not response_parts:
response_parts.append(Part(text=str(response)))
# Publish the agent's response as a completed message
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.TASK_STATE_COMPLETED,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.ROLE_AGENT,
parts=response_parts,
),
),
)
# Publish the agent's response and mark as completed
await updater.complete(
message=updater.new_agent_message(response_parts),
)
except asyncio.CancelledError:
raise
except Exception as e:
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(
state=TaskState.TASK_STATE_FAILED,
message=Message(
message_id=str(uuid.uuid4()),
role=Role.ROLE_AGENT,
parts=[Part(text=f"Agent error: {e}")],
),
),
)
await updater.update_status(
state=TaskState.TASK_STATE_FAILED,
message=updater.new_agent_message([Part(text=f"Agent error: {e}")]),
)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Handle cancellation by publishing a canceled status."""
task_id = context.task_id or str(uuid.uuid4())
context_id = context.context_id or str(uuid.uuid4())
await event_queue.enqueue_event(
TaskStatusUpdateEvent(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_CANCELED),
)
)
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
@@ -65,7 +65,7 @@ if __name__ == "__main__":
server = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
]
)
@@ -1,4 +1,3 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
ENABLE_INSTRUMENTATION=true
ENABLE_SENSITIVE_DATA=true
@@ -16,7 +16,7 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age
### Instrumentation
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution, but it's turned off by default. This sample demonstrates how to enable instrumentation via environment variables in `agent.manifest.yaml` and `agent.yaml`. The relevant environment variables are `ENABLE_INSTRUMENTATION` and `ENABLE_SENSITIVE_DATA`, which can be set to `true` to enable diagnostics and capture sensitive events respectively.
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution. Instrumentation is enabled by default. To also capture sensitive event payloads (prompts, tool arguments, etc.) set `ENABLE_SENSITIVE_DATA=true`. This sample demonstrates how to manage these settings via environment variables in `agent.manifest.yaml` and `agent.yaml`.
Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment.
@@ -17,8 +17,6 @@ template:
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
resources:
@@ -5,12 +5,10 @@ protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
cpu: "0.25"
memory: "0.5Gi"
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
value: true
+16 -16
View File
@@ -90,7 +90,7 @@ Example values below are illustrative. For entries not backed by a single public
column names the closest public surface, helper, or package-level initialization point that reads the
variable.
| package | class | env var | example value |
| package | class/module | env var | example value |
| --- | --- | --- | --- |
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` |
| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` |
@@ -117,21 +117,21 @@ variable.
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__SCHEMANAME` | `cr123_agentname` |
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__TENANTID` | `11111111-1111-1111-1111-111111111111` |
| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__AGENTAPPID` | `22222222-2222-2222-2222-222222222222` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_INSTRUMENTATION` | `true` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_SENSITIVE_DATA` | `false` |
| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_NAME` | `sample-agent` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_VERSION` | `1.0.0` |
| `agent-framework-core` | `enable_instrumentation()` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
| `agent-framework-core` | `observability` | `ENABLE_INSTRUMENTATION` | `true` |
| `agent-framework-core` | `observability` | `ENABLE_SENSITIVE_DATA` | `false` |
| `agent-framework-core` | `observability` | `ENABLE_CONSOLE_EXPORTERS` | `true` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` |
| `agent-framework-core` | `observability` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` |
| `agent-framework-core` | `observability` | `OTEL_SERVICE_NAME` | `sample-agent` |
| `agent-framework-core` | `observability` | `OTEL_SERVICE_VERSION` | `1.0.0` |
| `agent-framework-core` | `observability` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` |
| `agent-framework-devui` | `DevUI server` | `DEVUI_AUTH_TOKEN` | `my-devui-token` |
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` |
| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_MODEL` | `gpt-4o` |
+1 -1
View File
@@ -602,7 +602,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
]
[[package]]