Compare commits

..
Author SHA1 Message Date
Evan MattsonandGitHub 26cd5cc1bf Python: Bump Python version to 1.0.0rc5 and 1.0.0b260319 for a release. (#4807)
* Bump Python version to 1.0.0rc5 and 1.0.0b260319 for a release.

* update missed pkg versions

* Update changelog
2026-03-20 10:35:47 +09:00
James SturtevantandGitHub b4c4f5094e Python: Emit tool call events in GitHubCopilotAgent streaming (#4711)
* Emit tool call events in GitHubCopilotAgent streaming

_stream_updates now yields FunctionCallContent for TOOL_EXECUTION_START
and FunctionResultContent for TOOL_EXECUTION_COMPLETE events from the
Copilot SDK session. This enables DevUI and other consumers to display
tool calls during streaming agent execution. Previously only ASSISTANT_MESSAGE_DELTA, SESSION_IDLE, and SESSION_ERROR
were handled — tool execution events were silently dropped.

Signed-off-by: James Sturtevant <jsturtevant@gmail.com>

* Add some tests

Signed-off-by: James Sturtevant <jsturtevant@gmail.com>

* Respond to feedback

Signed-off-by: James Sturtevant <jsturtevant@gmail.com>

* Fix TOOL_EXECUTION_COMPLETE to use correct SDK types

- Read result text from session_events.Result.content (not ToolResult.text_result_for_llm)
- Read failure state from event.data.success/error (not result_obj.result_type/error)
- Handle ErrorClass.message and plain string errors
- Update tests to use session_events.Result and ErrorClass
- Add tests for string errors, success-with-error, and COMPLETE missing fields

Signed-off-by: James Sturtevant <jsturtevant@gmail.com>

---------

Signed-off-by: James Sturtevant <jsturtevant@gmail.com>
2026-03-20 01:27:36 +00:00
0cd40f8354 Python: [BREAKING] Refactor middleware layering and split Anthropic raw client (#4746)
* [BREAKING] Refactor middleware layering and raw clients

Reorder chat client layers so function invocation wraps chat middleware, and chat middleware stays outside telemetry while still running for each inner model call. Add middleware pipeline caching, refresh docs and samples, and split Anthropic into raw and public clients to match the standard layering model.

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

* Tighten typing ignores in ancillary modules

Add targeted typing ignores in workflow visualization and lab modules so pyright stays clean alongside the middleware refactor work.

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

* Fix categorize_middleware to unpack tuple/Sequence and use relative MRO assertions

- Broaden isinstance check in categorize_middleware from list to Sequence
  so tuples and other Sequence types are properly unpacked instead of
  being appended as a single item.
- Replace fragile hardcoded MRO index assertions in anthropic test with
  relative ordering via mro.index().
- Add regression tests for categorize_middleware with tuple, list, and
  None inputs.

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

* Fix middleware string decomposition, add middleware param to FunctionInvocationLayer, and add tests (#4710)

- Guard categorize_middleware Sequence check against str/bytes to prevent
  character-by-character decomposition of accidentally passed strings
- Add explicit middleware parameter to FunctionInvocationLayer.get_response
  and merge it into client_kwargs before categorization, fixing the
  inconsistency where only OpenAIChatClient supported this parameter
- Add assertions that RawAnthropicClient does not inherit convenience layers
- Add chat middleware cache test with non-empty base middleware
- Add tests for single unwrapped middleware item and string input

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

* Apply pre-commit auto-fixes

* Apply pre-commit auto-fixes

* Address review feedback for #4710: review comment fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-03-20 00:43:37 +00:00
cefda44283 Python: Emit TOOL_CALL_RESULT events when resuming after tool approval (#4758)
* Emit TOOL_CALL_RESULT events on approval resume (#4589)

When a tool call is approved via the interrupt/resume flow,
_resolve_approval_responses executes the tool and injects the result
into the messages array, but no TOOL_CALL_RESULT SSE event was yielded
to the client.

Changes:
- _resolve_approval_responses now returns the list of resolved
  function_result Content objects instead of None
- run_agent_stream yields ToolCallResultEvent for each resolved
  approval result after RunStartedEvent is emitted
- Add ToolCallResultEvent to ag_ui.core imports in _agent_run.py

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

* Apply pre-commit auto-fixes

* fix(ag-ui): address PR review feedback for #4589

1. _resolve_approval_responses now returns only approved results (not
   rejections) so TOOL_CALL_RESULT events are emitted only for executed
   tools. Rejection results are still written into message history.

2. Emit resolved TOOL_CALL_RESULT events in the no-updates fallback
   RUN_STARTED path so approval results are never lost.

3. Rewrite tests to use real FunctionTool with func and
   approval_mode='always_require' via StubAgent default_options,
   verifying actual tool execution output in TOOL_CALL_RESULT content.
   Added test for rejection not emitting TOOL_CALL_RESULT.

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

* Fix #4589: clean up approval resolution and add missing tests

- Extract duplicated TOOL_CALL_RESULT emission block into
  _make_approval_tool_result_events helper to prevent drift
- Remove dead rejection_results construction in _resolve_approval_responses;
  _replace_approval_contents_with_results already handles rejections inline
- Pass only approved_results (not all_results) to clarify the contract
- Add mixed approve/reject test validating the core splitting logic
- Add zero-updates test covering the no-updates fallback emission path
- Add direct unit test for _resolve_approval_responses return value

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

* Apply pre-commit auto-fixes

* Fix import sorting lint error in test_approval_result_event.py

Add blank line between first-party and third-party import groups
to satisfy ruff I001 rule.

Fixes #4589

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 00:41:46 +00:00
4afc088f01 Python: Emit AG-UI events for MCP tool calls, results, and text reasoning (#4760)
* Python: Emit AG-UI events for MCP tool calls, results, and text reasoning

Fixes #4213 — `_emit_content()` in the AG-UI layer only handled `text`,
`function_call`, `function_result`, `function_approval_request`, `usage`,
and `oauth_consent_request` content types. Foundry MCP content types
(`mcp_server_tool_call`, `mcp_server_tool_result`) and `text_reasoning`
fell through unhandled, producing no SSE events for AG-UI consumers.

Added three new handler functions wired into `_emit_content()`:

- `_emit_mcp_tool_call`: emits TOOL_CALL_START + TOOL_CALL_ARGS and
  tracks in FlowState for MESSAGES_SNAPSHOT inclusion
- `_emit_mcp_tool_result`: emits TOOL_CALL_END + TOOL_CALL_RESULT with
  full FlowState cleanup mirroring `_emit_tool_result`
- `_emit_text_reasoning`: emits the protocol-defined reasoning event
  sequence (ReasoningStart → MessageStart → MessageContent → MessageEnd
  → ReasoningEnd) with ReasoningEncryptedValueEvent for protected_data

* Add HTTP round-trip tests for MCP tool and reasoning SSE events

Exercises the full POST → SSE bytes → parse → validate pipeline for
mcp_server_tool_call, mcp_server_tool_result, text_reasoning, and
ReasoningEncryptedValueEvent content through FastAPI TestClient.

* Fix _emit_mcp_tool_result missing predictive_handler support (#4213)

- Add predictive_handler parameter to _emit_mcp_tool_result and mirror
  the apply_pending_updates + StateSnapshotEvent block from _emit_tool_result
- Forward predictive_handler from _emit_content to _emit_mcp_tool_result
- Add assertion for stored arguments in MCP tool call test
- Add test for predictive handler state snapshot after MCP tool result

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

* Apply pre-commit auto-fixes

* Refactor MCP tool emit functions and add missing tests (#4213)

- Extract _emit_tool_result_common shared helper to eliminate duplication
  between _emit_tool_result and _emit_mcp_tool_result
- Remove server_name prefix from tool_call_name in _emit_mcp_tool_call;
  display_name now equals tool_name directly
- Add test for tool_name fallback to 'mcp_tool' when tool_name is None
- Add test for output=None fallback to empty string in _emit_mcp_tool_result

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

* Address review feedback for #4213: review comment fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 00:41:37 +00:00
1272ec5adf Add automated stale issue and PR follow-up ping workflow (#4776)
* Add script to ping on stale issues/PRs

* Add script to ping on stale issues/PRs

* Fix stale issue/PR ping script review comments

- Rename TEAM_NAME env var to TEAM_SLUG for clarity
- Add actionable error messages for 403/404 team lookup failures
- Add contents:read permission for actions/checkout
- Use github.event.inputs context with fallback for scheduled runs
- Pin PyGithub to 2.6.0 for reproducible builds
- Fetch comments once in should_ping() to reduce API calls
- Make ping() retry loop idempotent (track comment/label state)
- Validate DAYS_THRESHOLD with helpful error for non-numeric input
- Fix timezone bug: use astimezone() instead of replace(tzinfo=)
- Add comprehensive unit tests (29 tests)

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 00:41:31 +00:00
47ead84753 Python: Support detail field in OpenAI Chat API image_url payload (#4756)
* Support detail field in OpenAI image_url payload (#4616)

Include the optional 'detail' field from Content.additional_properties
when building image_url payloads for the OpenAI Chat API, matching the
existing pattern used for 'filename' in document file payloads.

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

* Apply pre-commit auto-fixes

* Remove reproduction report

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

* Simplify detail extraction from additional_properties (#4616)

- Remove unnecessary hasattr check; additional_properties is always
  initialized as a dict on Content instances.
- Use 'is not None' instead of truthy check to be more precise.

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

* Apply pre-commit auto-fixes

* Remove detail allowlist in chat client to align with responses client

Replace the strict allowlist check ('low', 'high', 'auto') with an
isinstance(detail, str) check so that any valid string detail value is
passed through to OpenAI. This aligns the chat client behavior with the
responses client, which passes detail through unconditionally.

Also add test coverage for:
- Future/unknown string detail values being passed through
- Data URI images (covering the 'data' branch of the match)

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 00:33:57 +00:00
4c287c2424 Python: Fix MCP tool schema normalization for zero-argument tools missing 'properties' key (#4771)
* Fix zero-argument MCP tool schema missing 'properties' key (#4540)

MCP servers for zero-argument tools (e.g. matlab-mcp-core-server's
detect_matlab_toolboxes) declare inputSchema as {"type": "object"}
without a "properties" key. OpenAI's API requires "properties" to
be present on object schemas, causing a 400 invalid_request_error.

Normalize inputSchema at MCP ingestion in load_tools() to inject an
empty "properties": {} when it is missing from object-type schemas.

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

* Address review feedback for #4540: improve test robustness and add defensive guard

- Look up loaded functions by name instead of index to avoid brittle
  ordering assumptions
- Add negative-path test cases: non-object schema (type: string) and
  empty schema ({}) to verify guard clause skips them correctly
- Assert original inputSchema dicts are not mutated by load_tools()
- Add defensive guard for tool.inputSchema being None

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

* Address review feedback for #4540: Python: [Bug]: Local stdio MCP works for calculator but fails for official matlab-mcp-core-server on LM Studio /v1/responses

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 22:13:37 +00:00
westeyandGitHub 100086a276 Add docker-in-docker feature to dev container (#4794) 2026-03-19 19:18:46 +00:00
fc6721ca8e .NET: Trim src references and add utility to enforce (#4693)
* Trim src references and add utility to enforce

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 10:57:43 +00:00
4b21f38650 Python: Fix A2AAgent to invoke context providers before and after run (#4757)
* Fix A2AAgent to invoke context providers before and after run

A2AAgent.run() bypassed the context provider lifecycle (before_run/after_run)
that BaseAgent defines as a contract for all agents. This caused A2AAgent to
violate the semantic definition of BaseAgent, resulting in inconsistency with
other agent implementations.

The fix follows the same pattern used by WorkflowAgent:
- Create SessionContext and run before_run on all context providers before
  processing the A2A stream
- Collect response updates and run after_run on all context providers after
  the stream is fully consumed
- Auto-create a session when context providers are configured but no session
  is explicitly passed

Fixes #4754

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

* Apply pre-commit auto-fixes

* Remove reproduction report from repository

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

* Address PR review feedback for #4754

- Validate messages when no continuation_token: raise ValueError if
  normalized_messages is empty, preventing IndexError on messages[-1]
- Import BaseContextProvider/SessionContext from public agent_framework
  package instead of internal agent_framework._sessions module
- Add test for ValueError on run(None) without continuation_token

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

* Improve test coverage for empty-messages guard in A2AAgent.run (#4754)

- Parameterize test to cover both messages=None and messages=[] inputs
- Add test verifying run(None, continuation_token=...) does not raise

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 10:45:42 +00:00
bf8d9672e1 Python: Aggregate token usage across tool-call loop iterations in invoke_agent span (#4739)
* Fix invoke_agent span to aggregate token usage across LLM calls (#4062)

The FunctionInvocationLayer._get_response() loop was overwriting the
response on each iteration, so usage_details only reflected the last
chat completion call. Now tracks aggregated_usage across all iterations
using add_usage_details() and sets it on the returned response.

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

* Apply pre-commit auto-fixes

* Remove reproduction report artifact

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

* Apply pre-commit auto-fixes

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 06:41:33 +00:00
Peter IbekweandGitHub 5374dd47c5 .NET: Fix source generator bug that silently drops base class handler registrations for protocol-only partial executors (#4751)
* Fix source generator bug that silently drops base class handler registrations for protocol-only partial executors

* Fixed xml comments and variable naming.
2026-03-19 02:19:42 +00:00
29dfcbb584 .NET: Validate SkillsInstructionPrompt contains {0} placeholder in FileAgentSkillsProvider (#4642)
* Fix FileAgentSkillsProvider accepting SkillsInstructionPrompt without {0} placeholder (#4638)

BuildSkillsInstructionPrompt validated only format-string syntax via
string.Format(template, ""), which silently accepted templates without a
{0} placeholder. The generated skills list was then dropped from the final
instructions.

Tighten validation to format with a sentinel string and verify it appears
in the output, rejecting templates that do not reference argument 0 with
an ArgumentException.

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

* Fix netstandard2.0 compat and simplify prompt template validation (#4638)

- Replace string.Contains(string, StringComparison) with IndexOf for
  netstandard2.0/net472 compatibility
- Remove sentinel round-trip check; validate {0} directly on the raw
  template string using IndexOf
- Add positive test verifying custom SkillsInstructionPrompt with {0}
  is accepted and applied to output

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 00:18:39 +00:00
CopilotGitHubcrickmanCopilot Autofix powered by AIcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Chris Rickman
c9321b9028 .NET Compaction - Allow developer to specify a custom formatter for ToolResultCompactionStrategy (#4667)
* Initial plan

* Allow developer to specify custom formatter for ToolResultCompactionStrategy

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Refine shape

* Fix test expectation

* Potential fix for pull request finding

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
Co-authored-by: Chris Rickman <crickman@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 19:24:43 +00:00
f48c4512d3 Python: Simplify Python Poe tasks and unify package selectors (#4722)
* updated automation tasks and commands, with alias for the time being

* Restore aggregate test exclusions

Preserve the legacy all-tests scope for test --all by excluding lab and devui from the default aggregate sweep, while still allowing explicit package selection. Also ignore hidden/generated test directories such as .mypy_cache during aggregate discovery.

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

* updated versions in pre-commit

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 18:39:11 +00:00
CopilotGitHubcrickmanCopilot Autofix powered by AIcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Chris Rickman
d3d0100822 .NET Compaction - Add AsChatReducer() extension to expose CompactionStrategy as IChatReducer (#4664)
* Initial plan

* Add ChatStrategyExtensions.cs with AsChatReducer() extension method and tests

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Refactor message list creation in ReduceAsync method

* Remove unnecessary blank line in AsChatReducer method

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Fix test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris Rickman <crickman@microsoft.com>
2026-03-18 17:25:54 +00:00
acaf6b7054 Python: Fix ENABLE_SENSITIVE_DATA env var ignored when set after module import (#4743)
* Python: Re-read env vars in configure_otel_providers and enable_instrumentation (#4119)

Fix ENABLE_SENSITIVE_DATA and VS_CODE_EXTENSION_PORT env vars being ignored
when load_dotenv() runs after module import. The module-level
OBSERVABILITY_SETTINGS singleton cached env state at import time, and
configure_otel_providers() / enable_instrumentation() never re-read from
os.environ when parameters were None.

Both functions now construct a fresh ObservabilitySettings() to pick up
current env vars when explicit parameters are not provided, matching the
existing behavior of the env_file_path branch.

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

* Address PR review feedback for #4119: avoid throwaway ObservabilitySettings

- Add _read_bool_env/_read_int_env helpers to read env vars without
  constructing a full ObservabilitySettings (which calls create_resource())
- Replace ObservabilitySettings() in enable_instrumentation() and
  configure_otel_providers() else-branch with direct env reads
- Add enable_console_exporters parameter to configure_otel_providers()
  for override parity with enable_sensitive_data and vs_code_extension_port
- Propagate _resource and _executed_setup in the non-env_file_path branch
- Make existing tests hermetic (clear VS_CODE_EXTENSION_PORT and
  ENABLE_CONSOLE_EXPORTERS env vars)
- Add tests: enable_console_exporters env refresh, explicit param overrides
  for both enable_instrumentation() and configure_otel_providers()

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

* Address remaining review feedback for #4119

- Refresh enable_console_exporters in enable_instrumentation() for
  consistency with configure_otel_providers(), so env var changes
  after import are picked up by both public API functions
- Make test_configure_otel_providers_reads_env_vs_code_port hermetic
  by clearing ENABLE_CONSOLE_EXPORTERS from the environment
- Add test_enable_instrumentation_reads_env_console_exporters to
  cover the new refresh behavior

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

* Remove unconditional enable_console_exporters overwrite from enable_instrumentation() (#4119)

enable_instrumentation() is documented as not configuring exporters, so
managing enable_console_exporters there was a leaky abstraction. The
unconditional _read_bool_env call silently reset the value to False when
ENABLE_CONSOLE_EXPORTERS was absent from env, clobbering any value
previously set by configure_otel_providers(enable_console_exporters=True).

- Remove the unconditional overwrite line from enable_instrumentation()
- Replace test_enable_instrumentation_reads_env_console_exporters with
  test_enable_instrumentation_does_not_touch_console_exporters
- Add regression test: enable_instrumentation() does not clobber a
  previously configured enable_console_exporters value
- Add test: explicit enable_sensitive_data param still leaves
  enable_console_exporters untouched

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 15:58:22 +00:00
Hui MiaoandGitHub c2fec6b51c Python: Add foundry hosted agents samples for python (#4648)
* Add two hosted agent samples using the foundry agent

* Refactor formatting and improve readability in main.py

* Add agent-framework dependency to requirements and update copyright notice in main.py files

* Refactor agent imports and update credential handling in hosted agent samples

* Update agent framework dependency in requirements for hosted agents

* chore: update Python version to 3.14 and improve Dockerfile for hosted agents

* feat: add hosted agent samples for Azure AI with local tools and multi-agent workflows

* fix: update Azure AI client import and refactor agent initialization in hotel agent sample

* feat: add hosted agent samples for Seattle hotel search and writer-reviewer workflow

* fix: correct agent name in YAML configuration for local tools agent
2026-03-18 08:39:08 +00:00
705ed47a0b Python: Fix missing methods on the Content class in durable tasks (#4738)
* Fix Content serialization in DurableAgentStateUnknownContent (#4719)

DurableAgentStateUnknownContent.from_unknown_content() stored raw Content
objects without converting them to dicts, causing json.dumps to fail in
Azure Durable Functions' entity state serialization. This affected content
types not explicitly handled (e.g., mcp_server_tool_call/result).

The fix converts Content objects to dicts via to_dict() when storing in
DurableAgentStateUnknownContent, and restores them via Content.from_dict()
in to_ai_content().

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

* Add to_json and from_json methods to Content class (#4719)

Add to_json() and from_json() methods to the Content class to match the
serialization interface provided by SerializationMixin on other model classes.
Also fix pre-existing pyright type errors in durabletask's
DurableAgentStateUnknownContent.to_ai_content().

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

* Address PR review: add type guard, remove to_json, add fallback, and tests

- Remove Content.to_json() per reviewer request (comment 3)
- Add type guard in Content.from_json() for non-dict JSON (comments 1, 4)
- Wrap json.JSONDecodeError as ValueError for consistent exception contract
- Add try/except fallback in to_ai_content() for invalid Content dicts (comment 5)
- Add test_content_to_dict_exclude_none and test_content_to_dict_exclude_fields (comment 2)
- Add test_unknown_content_to_ai_content_fallback_on_invalid_type_dict (comment 5)

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

* Apply pre-commit auto-fixes

* Address review feedback for #4719: review comment fixes

* Remove Content.from_json, move logic to consuming code (#4719)

Remove the from_json convenience method from Content class per review
feedback. This is the same trivial json.loads + from_dict wrapper as
to_json which was already removed. Consumers should call json.loads
and Content.from_dict directly.

Update tests to use Content.from_dict(json.loads(...)) pattern and
remove from_json-specific error handling tests (those errors are
already covered by json.loads and Content.from_dict).

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-18 08:08:44 +00:00
192a283c9a Python: Reduce Azure chat client import overhead (#4744)
* Reduce Azure chat client import overhead

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

* Fix Azure chat client type annotations and add _parse_text_from_openai tests

- Move Choice and ChunkChoice imports under TYPE_CHECKING to avoid
  runtime import cost (from __future__ annotations is already present)
- Restore proper typed signature (Choice | ChunkChoice) instead of Any
- Add direct unit tests for _parse_text_from_openai covering:
  - Choice with message content
  - ChunkChoice with delta content
  - Refusal branch for both Choice and ChunkChoice
  - No content/no refusal returning None
  - None delta (async content filtering) returning None

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-03-18 08:05:42 +00:00
Peter IbekweandGitHub c74b1b08eb .NET: Fix race condition issue in FanInEdge while processing messages. (#4662)
* Fix race condition issue in FanInEdge while processing messages.

* refactored to limit the code segment under lock.

* Remove extra materialization of the result.

* Added comment to clarify future changes if process message is made async.
2026-03-18 00:36:10 +00:00
Shyju KrishnankuttyandGitHub 7c85f98c27 .NET: Align sample build configuration with test runner in CI (#4735)
* Run azure functions integration tests in release mode.

* Use debug when in debug build.
2026-03-17 20:20:14 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e6f8909ec Bump pyjwt from 2.11.0 to 2.12.0 in /python (#4699)
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:06:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
008fe23585 Bump actions/upload-artifact from 4 to 7 (#4373)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:05:55 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6af0511e2b Bump MishaKav/pytest-coverage-comment from 1.2.0 to 1.6.0 (#4543)
Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.2.0 to 1.6.0.
- [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases)
- [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.2.0...v1.6.0)

---
updated-dependencies:
- dependency-name: MishaKav/pytest-coverage-comment
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:37 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6dbb0a5bb4 Bump danielpalme/ReportGenerator-GitHub-Action from 5.5.1 to 5.5.3 (#4542)
Bumps [danielpalme/ReportGenerator-GitHub-Action](https://github.com/danielpalme/reportgenerator-github-action) from 5.5.1 to 5.5.3.
- [Release notes](https://github.com/danielpalme/reportgenerator-github-action/releases)
- [Commits](https://github.com/danielpalme/reportgenerator-github-action/compare/5.5.1...5.5.3)

---
updated-dependencies:
- dependency-name: danielpalme/ReportGenerator-GitHub-Action
  dependency-version: 5.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
21af304c7d Bump actions/setup-dotnet from 5.1.0 to 5.2.0 (#4541)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:07 +00:00
94af83680e Python: Fix RUN_FINISHED.interrupt to accumulate all interrupts when multiple tools need approval (#4717)
* Fix flow.interrupts overwrite when multiple tools need approval (#4590)

Change flow.interrupts assignment to append so that all interrupt entries
accumulate when multiple tools require approval in a single turn.

Both _run_common.py and _agent_run.py used assignment (=) which caused
each new interrupt to overwrite the previous one. Switching to append()
ensures RUN_FINISHED.interrupt contains all pending approvals.

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

* Add test for streaming path with multiple confirm_changes interrupts (#4590)

Add integration test exercising run_agent_stream with multiple predictive
tool calls requiring confirmation. Verifies that flow.interrupts.append()
correctly accumulates all interrupt entries and they appear in the
RUN_FINISHED event.

Also confirms FlowState already declares interrupts field with
default_factory=list, addressing the AttributeError concern from review.

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

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 12:44:44 +00:00
cdb51e6a41 Python: fix thread serialization for multi-turn tool calls (#4684)
* Python: strip fc_id from loaded history

* Move fc_id replay handling into Responses client

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

* Remove unnecessary pytest asyncio marker

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

* Add Responses integration test for fc_id replay

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

* removed old arg

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 10:00:04 +00:00
cbcdb2d29e .NET: Add durable workflow support (#4436)
* .NET: [Feature Branch] Add basic durable workflow support (#3648)

* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.

* .NET: [Feature Branch] Add Azure Functions hosting support for durable workflows (#3935)

* Adding azure functions workflow support.

* - PR feedback fixes.
- Add example to demonstrate complex Object as payload.

* rename instanceId to runId.

* Use custom ITaskOrchestrator to run orchestrator function.

* .NET: [Feature Branch] Adding support for events & shared state in durable workflows (#4020)

* Adding support for events & shared state in durable workflows.

* PR feedback fixes

* PR feedback fixes.

* Add YieldOutputAsync calls to 05_WorkflowEvents sample executors

The integration test asserts that WorkflowOutputEvent is found in the
stream, but the sample executors only used AddEventAsync for custom
events and never called YieldOutputAsync. Since WorkflowOutputEvent is
only emitted via explicit YieldOutputAsync calls, the assertion would
fail. Added YieldOutputAsync to each executor to match the test
expectation and demonstrate the API in the sample.

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

* Fix deserialization to use shared serializer options.

* PR feedback updates.

* Sample cleanup

* PR feedback fixes

* Addressing PR review feedback for DurableStreamingWorkflowRun

   - Use -1 instead of 0 for taskId in TaskFailedException when task ID is not relevant.
   - Add [NotNullWhen(true)] to TryParseWorkflowResult out parameter following .NET TryXXX conventions.

---------

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

* .NET: [Feature Branch]  Add nested sub-workflow support for durable workflows (#4190)

* .NET: [Feature Branch] Add nested sub-workflow support for durable workflows

* fix readme path

* Switch Orchestration output from string to DurableWorkflowResult.

* PR feedback fixes

* Minor cleanup based on PR feedback.

* .NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358)

* Add Azure Functions HITL workflow sample

Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop
workflow support with HTTP endpoints for status checking and approval responses.

The sample includes:
- ExpenseReimbursement workflow with RequestPort for manager approval
- Custom HTTP endpoint to check workflow status and pending approvals
- Custom HTTP endpoint to send approval responses via RaiseEventAsync
- demo.http file with step-by-step interaction examples

* PR feedback fixes

* Minor comment cleanup

* Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts      need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup

* fix  for PR feedback

* PR feedback updates

* Improvements to samples

* Improvements to README

* Update samples to use parallel request ports.

* Unit tests

* Introduce local variables to improve readability of Workflows.Workflows access patter

* Use GitHub-style callouts and add PowerShell command variants in HITL sample README

* Add changelog entries for durable workflow support (#4436)

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

* Bump Microsoft.DurableTask.Worker to 1.19.1 to fix version downgrade

Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.13.1 requires
Microsoft.DurableTask.Worker >= 1.19.1 via its transitive dependency on
Microsoft.DurableTask.Worker.Grpc 1.19.1.

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

* Fix broken markdown links in durable workflow sample READMEs

- Create Workflow/README.md with environment setup docs
- Fix ../README.md -> ../../README.md in ConsoleApps 01, 02, 03, 08
- Fix SubWorkflows relative path (3 levels -> 4 levels up)
- Fix dead Durable Task Scheduler URL

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

* Fix build errors from main merge: Throw conflict, ExecuteAsync rename, GetNewSessionAsync rename

- Remove InjectSharedThrow from DurableTask csproj (uses Workflows' internal Throw via InternalsVisibleTo)
- Update ExecuteAsync -> ExecuteCoreAsync with WorkflowTelemetryContext.Disabled
- Update GetNewSessionAsync -> CreateSessionAsync

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

* Move durable workflow samples to 04-hosting/DurableWorkflows

Aligns with main branch sample reorganization where durable samples
live under 04-hosting/ (alongside DurableAgents/).

- Move samples/Durable/Workflow/ -> samples/04-hosting/DurableWorkflows/
- Add Directory.Build.props matching DurableAgents pattern
- Update slnx project paths
- Update integration test sample paths
- Update README cd paths and cross-references

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

* Fix build errors: remove duplicate base class members, update renamed APIs

- Remove duplicate OutputLog, WriteInputAsync, CreateTestTimeoutCts, etc. from
  ConsoleAppSamplesValidation (already in SamplesValidationBase)
- Update AddFanInEdge -> AddFanInBarrierEdge in workflow samples
- Update GetNewSessionAsync -> CreateSessionAsync in workflow samples
- Update SourceId -> ExecutorId (obsolete) in workflow samples

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

* Fix dotnet format issues: add UTF-8 BOM and remove unused using

- Add UTF-8 BOM to 20 .cs files across DurableTask, AzureFunctions,
  unit tests, and workflow samples
- Remove unnecessary using directive in 07_SubWorkflows/Executors.cs

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

* Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README

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

* Fix GetExecutorName to handle agent names with underscores

Split on last underscore instead of first, and validate that the
suffix is a 32-char hex string (sanitized GUID) before stripping it.
This prevents truncation of agent names like 'my_agent' when the
executor ID is 'my_agent_<guid>'.

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

* Align DurableTask.Client.AzureManaged to 1.19.1

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

* Bump DurableTask and Azure Functions extension package versions

- DurableTask.* packages: 1.19.1 -> 1.22.0
- Functions.Worker.Extensions.DurableTask: 1.13.1 -> 1.16.0
- Functions.Worker.Extensions.DurableTask.AzureManaged: 1.0.1 -> 1.5.0 (telemetry bug fix)

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

* Bump DurableTask SDK packages to 1.22.0

- DurableTask.Client: 1.19.1 -> 1.22.0
- DurableTask.Client.AzureManaged: 1.19.1 -> 1.22.0
- DurableTask.Worker: 1.19.1 -> 1.22.0
- DurableTask.Worker.AzureManaged: 1.19.1 -> 1.22.0
- Azure Functions extensions kept at original versions (1.13.1/1.0.1) due to
  host-side DurableTask.Core 3.7.0 incompatibility with newer extensions

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

* Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0"

* Add the local.settings.json files to the sample which were previously ignored. This aligns with our other samples.

* Increase timeout for tests as CI has them failing transiently.

* increaset timeout value for azure functions integration tests.

* Add YieldsOutput(string) to workflow shared state sample executors

ValidateOrder and EnrichOrder call YieldOutputAsync with string messages,
but only their TOutput (OrderDetails) was in the allowed yield types.
This caused TargetInvocationException in the WorkflowSharedState sample
validation integration test.

* Downgrade the durable packages to 1.18.0

* Downgrading Worker.Extensions.DurableTask to 1.12.1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 23:00:50 +00:00
0fdcfd0f4c Python: preserve A2A message context_id (#4686)
* Python: forward A2A context_id

* Avoid duplicating A2A context ids

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 21:41:31 +00:00
Giles OdigweandGitHub 414496dda7 fix: Azure Redis sample missing session for history persistence (#4692) 2026-03-16 21:34:21 +00:00
55011b7258 Python: Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4716)
* Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4682)

Remove the catch-all dedup branch that used (role, hash(content_str)) as a
dedup key. This incorrectly treated any two messages with the same role and
identical content as duplicates, dropping valid repeated messages (e.g., a
user saying 'yes' to confirm two separate things).

The tool-specific dedup branches (tool results by call_id, assistant tool
calls by call_id tuple) remain unchanged as they correctly identify true
protocol-level duplicates.

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

* Address review: consecutive-duplicate detection for non-tool messages (#4682)

- Replace blanket dedup removal with consecutive-duplicate detection:
  only skip a message if the immediately preceding message has the same
  role and content, preserving protection against upstream replays while
  allowing identical messages at different conversation points.
- Strengthen test assertions to verify message identity and order, not
  just list length.
- Add tests for consecutive duplicate skipping, non-consecutive
  preservation, and messages with contents=None.

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

* Apply pre-commit auto-fixes

* Use message_id for deduplication instead of content hashing

Deduplicate general messages by message_id when available, replacing
the consecutive-duplicate content check. Two messages with the same id
are definitively the same message (upstream replay), while identical
content with distinct ids (e.g. repeated "yes" confirmations) is
preserved. Messages without a message_id are always kept.

* Fix message_id dedup: truthy check, content-hash fallback, log safety

- Use truthy check (`if msg.message_id`) instead of `is not None` so
  empty-string IDs fall through to content-hash dedup rather than
  collapsing unrelated messages.
- Add content-hash fallback for messages without message_id, preventing
  false negatives from integrations that don't set IDs.
- Remove raw message_id from log format string (addresses log-injection
  surface with control characters).
- Add tests for empty-string message_id edge cases.
- Update existing tests to reflect content-hash dedup behavior.

Fixes #4682

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 17:47:33 +00:00
CopilotGitHubcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
bf0af178bd .NET - Fix flaky workflows test (#4700)
* Initial plan

* Fix flaky test: initialize creationTime 1 second in the past

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
2026-03-16 17:33:08 +00:00
1b7940c91e Python: keep MCP cleanup on the owner task (#4687)
* Python: keep MCP cleanup on owner task

* Avoid MCP owner task deadlocks

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

* Fix MCP owner-task timeout tests

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 13:54:05 +00:00
Laveesh RohraandGitHub 2f4c4aa614 Python: Remove bad dependency (#4696)
* Remove bad dependency in requirements

* Remove bad dependency in requirements.txt
2026-03-13 23:15:56 +00:00
Eduard van ValkenburgandGitHub 052ba7be07 Python: normalize empty MCP tool output to null (#4683)
* Python: normalize empty MCP tool output to null

* Python: hardcode null for empty MCP output
2026-03-13 20:03:48 +00:00
Chris GillumandGitHub c67d3523ae .NET: [Durable Agents] Filter empty AIContent from durable agent state responses (#4670)
* Filter empty AIContent from durable agent state responses

Prevent opaque AIContent objects (e.g., with only RawRepresentation set)
from being stored in durable entity state, where they serialize to empty
JSON payloads. Base AIContent instances are kept only if they have
Annotations or AdditionalProperties.

Fixes https://github.com/microsoft/agent-framework/issues/4481

* Update CHANGELOG.md and fix linter violation
2026-03-13 18:16:46 +00:00
Shyju KrishnankuttyandGitHub 83ce6a9602 Sanitize user input in log statements for durable agent samples. (#4656) 2026-03-13 17:38:55 +00:00
50fdcbaf57 Python: chore(python): improve dependency range automation (#4343)
* chore(python): improve dependency range automation

- tighten dependency bounds and coding standards guidance\n- add dependency range validation workflow, reporting, and issue automation\n- update related tests and dependency pins for compatibility

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

* updated text and pyarrow

* new lock

* fixed workflow

* updated deps

* fix tiktoken

* chore(python): refine dependency validation workflows

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

* docs(python): add high-level dependency validation comments

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

* WIP

* added additional comments and excludes

* added dev dependency handling and workflow and updates to package ranges

* added readme and simplified commands

* fix markers

* chore(python): address dependency review feedback

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

* Tighten dependency bounds, remove stale overrides, restore Python 3.10 support

- Apply dependency bound policy across all packages: stable >=1.0 deps use
  >=floor,<next_major; pre-1.0/prerelease deps use validated hard-bounded ranges
- Remove stale root tool.uv.override-dependencies (uvicorn, websockets, grpcio)
- Lower github_copilot requires-python to >=3.10 with github-copilot-sdk gated
  behind python_version >= 3.11 marker; import raises ImportError on 3.10
- Skip github_copilot pyright/mypy/test tasks on Python <3.11
- Use version-conditional pyrightconfig for samples on Python 3.10
- Add compatibility fix in core responses client for older openai typed dicts
- Normalize uv.lock prerelease mode and refresh dev dependencies
- Update CODING_STANDARD.md, DEV_SETUP.md, and package management skill docs

Closes #902

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

* small tweaks

* add note in workflow

* fix workflows and several versions

* fix duplicate

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 12:32:37 +00:00
SergeyMenshykhandGitHub 67b0282813 Bump rollup from 7.5.9 to 7.5.11 (#4688) 2026-03-13 12:30:29 +00:00
Roger BarretoandGitHub 0009e330af Fix hosted agent samples Docker build failures due to experimental API warnings (#4641)
Add #pragma warning disable directives to suppress experimental API
diagnostics that cause build errors in Docker isolation (where repo-level
Directory.Build.props is not inherited):

- AgentWithHostedMCP: suppress MEAI001 (HostedMcpServerTool) and OPENAI001
  (GetResponsesClient)
- FoundrySingleAgent: suppress CA2252 (AIProjectClient preview features)
- FoundryMultiAgent: suppress CA2252 (AIProjectClient preview features)

Fixes #4365
2026-03-13 10:13:59 +00:00
a4b9539b62 [BREAKING] Python: clean up kwargs across agents, chat clients, tools, and sessions (#4581)
* Python: clean up kwargs across agents, chat clients, tools, and sessions (#3642)

Audit and refactor public **kwargs usage across core agents, chat clients,
tools, sessions, and provider packages per the migration strategy codified
in CODING_STANDARD.md.

Key changes:
- Add explicit runtime buckets: function_invocation_kwargs and client_kwargs
  on RawAgent.run() and chat client get_response() layers.
- Refactor FunctionTool to prefer explicit ctx: FunctionInvocationContext
  injection; legacy **kwargs tools still work via _forward_runtime_kwargs.
- Refactor Agent.as_tool() to use direct JSON schema, always-streaming
  wrapper, approval_mode parameter, and UserInputRequiredException
  propagation (integrates PR #4568 behavior).
- Remove implicit session bleeding into FunctionInvocationContext; tools
  that need a session must receive it via function_invocation_kwargs.
- Lower chat-client layers after FunctionInvocationLayer accept only
  compatibility **kwargs (client_kwargs flattened, function_invocation_kwargs
  ignored).
- Add layered docstring composition from Raw... implementations via
  _docstrings.py helper.
- Clean up provider constructors to use explicit additional_properties.
- Deprecation warnings on legacy direct kwargs paths.
- Update samples, tests, and typing across all 23 packages.

Resolves #3642

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

* clarified docstring

* feedback fixes

* Add unit tests for _docstrings.py build/apply helpers

Tests cover: no docstring source, no extra kwargs, appending to existing
Keyword Args section, inserting after Args, inserting in plain docstrings,
multiline descriptions, ordering, and apply_layered_docstring.

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

* Add test for propagate_session TypeError on non-AgentSession values

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

* Add tests for multi-content and empty UserInputRequiredException propagation

Cover the branching logic in _try_execute_function_calls for:
- Multiple user_input_request items in a single exception (extra_user_input_contents path)
- Empty contents list (fallback function_result path)

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

* Add tests for DurableAIAgent.get_session forwarding service_session_id

Verifies get_session correctly forwards service_session_id and session_id
to the executor's get_new_session, replacing the removed kwargs test.

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

* Simplify ag-ui test stub to read session from client_kwargs only

Remove dual-mode detection (client_kwargs vs raw kwargs fallback) from
the test mock. Session is now read exclusively from client_kwargs,
matching the settled public calling convention.

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

* updated create and get sessions in durable

* fixed docstrings

* fix test

* updated session handling

* updated from main

* updated tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 08:58:32 +00:00
Eduard van ValkenburgandGitHub b7990908fe fix duplicate names between supplied tools and mcp servers (#4649) 2026-03-13 08:22:56 +00:00
84bae0f42a Python: Fix type hint for Case and Default (#3985)
* Fix type hint for `Case` and `Default`

* Add test

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-03-13 08:17:24 +00:00
245 changed files with 16468 additions and 2364 deletions
+1
View File
@@ -3,6 +3,7 @@
"image": "mcr.microsoft.com/devcontainers/dotnet",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "2"
},
+207
View File
@@ -0,0 +1,207 @@
# Copyright (c) Microsoft. All rights reserved.
"""Scan open issues and PRs for stale follow-ups from external authors.
If a team member commented and the external author hasn't replied within
DAYS_THRESHOLD days, post a reminder comment and add the 'needs-info' label.
"""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime, timezone
from github import Auth, Github, GithubException
from github.Issue import Issue
from github.IssueComment import IssueComment
PING_COMMENT = (
"@{author}, friendly reminder — this issue is waiting on your response. "
"Please share any updates when you get a chance. (This is an automated message.)"
)
LABEL = "needs-info"
def get_team_members(g: Github, org: str, team_slug: str) -> set[str]:
"""Fetch active team member usernames."""
try:
org_obj = g.get_organization(org)
team = org_obj.get_team_by_slug(team_slug)
return {m.login for m in team.get_members()}
except GithubException as exc:
if exc.status in (403, 404):
print(
f"ERROR: Failed to fetch team members for {org}/{team_slug} "
f"(HTTP {exc.status}). Check that the token has the 'read:org' "
f"scope and that the team slug '{team_slug}' is correct."
)
else:
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
sys.exit(1)
except Exception as exc:
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
sys.exit(1)
def find_last_team_comment(
comments: list[IssueComment], team_members: set[str]
) -> IssueComment | None:
"""Return the most recent comment from a team member, or None."""
for comment in reversed(comments):
if comment.user and comment.user.login in team_members:
return comment
return None
def author_replied_after(
comments: list[IssueComment], author: str, after: datetime
) -> bool:
"""Check if the issue author commented after the given timestamp."""
for comment in comments:
if (
comment.user
and comment.user.login == author
and comment.created_at > after
):
return True
return False
def should_ping(
issue: Issue,
team_members: set[str],
days_threshold: int,
now: datetime,
) -> bool:
"""Determine whether this issue/PR should be pinged."""
author = issue.user.login
# Skip if author is a team member
if author in team_members:
return False
# Skip if already labeled
if any(label.name == LABEL for label in issue.labels):
return False
# Skip if no comments at all
if issue.comments == 0:
return False
# Fetch comments once for both lookups
comments = list(issue.get_comments())
# Find last team member comment
last_team_comment = find_last_team_comment(comments, team_members)
if last_team_comment is None:
return False
# Skip if author replied after the last team comment
if author_replied_after(comments, author, last_team_comment.created_at):
return False
# Check if enough days have passed
days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days
if days_since < days_threshold:
return False
return True
def ping(issue: Issue, dry_run: bool) -> bool:
"""Post a reminder comment and add the needs-info label. Returns True on success."""
author = issue.user.login
kind = "PR" if issue.pull_request else "Issue"
if dry_run:
print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})")
return True
max_retries = 3
commented = False
labeled = False
for attempt in range(1, max_retries + 1):
try:
if not commented:
issue.create_comment(PING_COMMENT.format(author=author))
commented = True
if not labeled:
issue.add_to_labels(LABEL)
labeled = True
print(f" Pinged {kind} #{issue.number} (@{author})")
return True
except Exception as exc:
if attempt < max_retries:
wait = 2 ** attempt # 2s, 4s
print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...")
time.sleep(wait)
else:
print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}")
return False
def main() -> None:
token = os.environ.get("GITHUB_TOKEN")
if not token:
print("ERROR: GITHUB_TOKEN environment variable is required")
sys.exit(1)
repository = os.environ.get("GITHUB_REPOSITORY")
if not repository:
print("ERROR: GITHUB_REPOSITORY environment variable is required")
sys.exit(1)
team_slug = os.environ.get("TEAM_SLUG")
if not team_slug:
print("ERROR: TEAM_SLUG environment variable is required")
sys.exit(1)
days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4")
try:
days_threshold = int(days_threshold_raw)
except ValueError:
print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'")
sys.exit(1)
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
org = repository.split("/")[0]
if dry_run:
print("Running in DRY RUN mode — no comments or labels will be applied.\n")
g = Github(auth=Auth.Token(token))
repo = g.get_repo(repository)
print(f"Fetching team members for {org}/{team_slug}...")
team_members = get_team_members(g, org, team_slug)
print(f"Found {len(team_members)} team members.\n")
now = datetime.now(timezone.utc)
pinged = []
failed = []
scanned = 0
print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n")
for issue in repo.get_issues(state="open"):
scanned += 1
if should_ping(issue, team_members, days_threshold, now):
if ping(issue, dry_run):
pinged.append(issue.number)
else:
failed.append(issue.number)
print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.")
if pinged:
print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}")
if failed:
print(f"Failed: {', '.join(f'#{n}' for n in failed)}")
sys.exit(1)
if __name__ == "__main__":
main()
+293
View File
@@ -0,0 +1,293 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for stale_issue_pr_ping.py."""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch
import pytest
# Ensure the script directory is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
from stale_issue_pr_ping import (
LABEL,
PING_COMMENT,
author_replied_after,
find_last_team_comment,
get_team_members,
main,
ping,
should_ping,
)
TEAM = {"alice", "bob"}
NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_comment(login: str | None, created_at: datetime) -> MagicMock:
"""Create a mock IssueComment."""
c = MagicMock()
if login is None:
c.user = None
else:
c.user = MagicMock()
c.user.login = login
c.created_at = created_at
return c
def _make_label(name: str) -> MagicMock:
lbl = MagicMock()
lbl.name = name
return lbl
def _make_issue(
author: str = "external",
labels: list[str] | None = None,
comment_count: int = 1,
comments: list[MagicMock] | None = None,
pull_request: bool = False,
number: int = 42,
) -> MagicMock:
issue = MagicMock()
issue.user = MagicMock()
issue.user.login = author
issue.number = number
issue.labels = [_make_label(n) for n in (labels or [])]
issue.comments = comment_count
issue.pull_request = MagicMock() if pull_request else None
if comments is not None:
issue.get_comments.return_value = comments
return issue
# ---------------------------------------------------------------------------
# find_last_team_comment
# ---------------------------------------------------------------------------
class TestFindLastTeamComment:
def test_returns_last_team_comment(self):
c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc))
c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc))
assert find_last_team_comment([c1, c2, c3], TEAM) is c3
def test_returns_none_when_no_team_comments(self):
c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc))
assert find_last_team_comment([c1], TEAM) is None
def test_returns_none_for_empty_list(self):
assert find_last_team_comment([], TEAM) is None
def test_skips_deleted_user(self):
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc))
assert find_last_team_comment([c1, c2], TEAM) is c2
def test_only_deleted_users(self):
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
assert find_last_team_comment([c1], TEAM) is None
# ---------------------------------------------------------------------------
# author_replied_after
# ---------------------------------------------------------------------------
class TestAuthorRepliedAfter:
def test_author_replied(self):
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
assert author_replied_after([c1], "external", after) is True
def test_author_not_replied(self):
after = datetime(2026, 3, 5, tzinfo=timezone.utc)
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
assert author_replied_after([c1], "external", after) is False
def test_different_user_replied(self):
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc))
assert author_replied_after([c1], "external", after) is False
def test_deleted_user_comment(self):
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc))
assert author_replied_after([c1], "external", after) is False
# ---------------------------------------------------------------------------
# should_ping
# ---------------------------------------------------------------------------
class TestShouldPing:
def test_should_ping_stale_issue(self):
team_comment = _make_comment("alice", NOW - timedelta(days=5))
issue = _make_issue(comments=[team_comment], comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is True
def test_skip_team_member_author(self):
issue = _make_issue(author="alice", comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_skip_already_labeled(self):
issue = _make_issue(labels=[LABEL], comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_skip_no_comments(self):
issue = _make_issue(comment_count=0)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_skip_no_team_comment(self):
c = _make_comment("external", NOW - timedelta(days=5))
issue = _make_issue(comments=[c], comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_skip_author_replied(self):
team_c = _make_comment("alice", NOW - timedelta(days=5))
author_c = _make_comment("external", NOW - timedelta(days=3))
issue = _make_issue(comments=[team_c, author_c], comment_count=2)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_skip_not_enough_days(self):
team_comment = _make_comment("alice", NOW - timedelta(days=2))
issue = _make_issue(comments=[team_comment], comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is False
def test_aware_datetime_handled(self):
"""Timezone-aware datetimes should not be mangled by astimezone."""
aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc)
team_comment = _make_comment("alice", aware_dt)
issue = _make_issue(comments=[team_comment], comment_count=1)
assert should_ping(issue, TEAM, 4, NOW) is True
def test_naive_datetime_handled(self):
"""Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone."""
naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None)
team_comment = _make_comment("alice", naive_dt)
issue = _make_issue(comments=[team_comment], comment_count=1)
# astimezone on naive datetime treats it as local time; just verify no crash
should_ping(issue, TEAM, 4, NOW)
# ---------------------------------------------------------------------------
# ping
# ---------------------------------------------------------------------------
class TestPing:
def test_dry_run(self, capsys):
issue = _make_issue()
assert ping(issue, dry_run=True) is True
issue.create_comment.assert_not_called()
assert "DRY RUN" in capsys.readouterr().out
def test_success(self, capsys):
issue = _make_issue()
assert ping(issue, dry_run=False) is True
issue.create_comment.assert_called_once()
issue.add_to_labels.assert_called_once_with(LABEL)
@patch("stale_issue_pr_ping.time.sleep")
def test_retry_on_failure(self, mock_sleep):
issue = _make_issue()
issue.create_comment.side_effect = [Exception("net error"), None]
assert ping(issue, dry_run=False) is True
assert issue.create_comment.call_count == 2
mock_sleep.assert_called_once()
@patch("stale_issue_pr_ping.time.sleep")
def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep):
"""If create_comment succeeds but add_to_labels fails, retry should not re-comment."""
issue = _make_issue()
issue.add_to_labels.side_effect = [Exception("label error"), None]
assert ping(issue, dry_run=False) is True
# Comment should only be created once even though there were 2 attempts
assert issue.create_comment.call_count == 1
assert issue.add_to_labels.call_count == 2
@patch("stale_issue_pr_ping.time.sleep")
def test_all_retries_fail(self, mock_sleep):
issue = _make_issue()
issue.create_comment.side_effect = Exception("permanent error")
assert ping(issue, dry_run=False) is False
assert issue.create_comment.call_count == 3
# ---------------------------------------------------------------------------
# get_team_members
# ---------------------------------------------------------------------------
class TestGetTeamMembers:
def test_success(self):
g = MagicMock()
member = MagicMock()
member.login = "alice"
g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member]
assert get_team_members(g, "org", "my-team") == {"alice"}
def test_403_error_message(self, capsys):
from github import GithubException
g = MagicMock()
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
403, {"message": "Forbidden"}, None
)
with pytest.raises(SystemExit):
get_team_members(g, "org", "my-team")
out = capsys.readouterr().out
assert "read:org" in out
assert "403" in out
def test_404_error_message(self, capsys):
from github import GithubException
g = MagicMock()
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
404, {"message": "Not Found"}, None
)
with pytest.raises(SystemExit):
get_team_members(g, "org", "bad-slug")
out = capsys.readouterr().out
assert "read:org" in out
assert "bad-slug" in out
def test_generic_error(self, capsys):
g = MagicMock()
g.get_organization.side_effect = RuntimeError("boom")
with pytest.raises(SystemExit):
get_team_members(g, "org", "team")
# ---------------------------------------------------------------------------
# main – env var validation
# ---------------------------------------------------------------------------
class TestMain:
@patch.dict(os.environ, {
"GITHUB_TOKEN": "tok",
"GITHUB_REPOSITORY": "org/repo",
"TEAM_SLUG": "my-team",
"DAYS_THRESHOLD": "abc",
}, clear=True)
def test_invalid_days_threshold(self, capsys):
with pytest.raises(SystemExit):
main()
assert "numeric" in capsys.readouterr().out
@patch.dict(os.environ, {
"GITHUB_TOKEN": "tok",
"GITHUB_REPOSITORY": "org/repo",
}, clear=True)
def test_missing_team_slug(self, capsys):
with pytest.raises(SystemExit):
main()
assert "TEAM_SLUG" in capsys.readouterr().out
+4 -4
View File
@@ -85,7 +85,7 @@ jobs:
workflow-samples
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
@@ -165,7 +165,7 @@ jobs:
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -281,7 +281,7 @@ jobs:
# Generate test reports and check coverage
- name: Generate test reports
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
with:
reports: "./TestResults/Coverage/**/*.cobertura.xml"
targetdir: "./TestResults/Reports"
@@ -289,7 +289,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
@@ -50,7 +50,7 @@ jobs:
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
+4 -6
View File
@@ -75,7 +75,7 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run fmt, lint, pyright in parallel across packages
- name: Run syntax and pyright across packages
run: uv run poe check-packages
samples-markdown:
@@ -104,10 +104,8 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run samples lint
run: uv run poe samples-lint
- name: Run samples syntax check
run: uv run poe samples-syntax
- name: Run samples checks
run: uv run poe check -S
- name: Run markdown code lint
run: uv run poe markdown-code-lint
@@ -140,4 +138,4 @@ jobs:
- name: Run Mypy
env:
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
run: uv run poe ci-mypy
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
@@ -0,0 +1,216 @@
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
name: Python - Dependency Range Validation
on:
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
dependency-range-validation:
name: Dependency Range Validation
runs-on: ubuntu-latest
env:
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
# then we will have to reevaluate.
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run dependency range validation
id: validate_ranges
# Keep workflow running so we can still publish diagnostics from this run.
continue-on-error: true
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
working-directory: ./python
- name: Upload dependency range report
# Always publish the report so failures are inspectable even when validation fails.
if: always()
uses: actions/upload-artifact@v7
with:
name: dependency-range-results
path: python/scripts/dependencies/dependency-range-results.json
if-no-files-found: warn
- name: Create issues for failed dependency candidates
# Always process the report so failed candidates create actionable tracking issues.
if: always()
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
if (!fs.existsSync(reportPath)) {
core.warning(`No dependency range report found at ${reportPath}`)
return
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
const dependencyFailures = []
for (const packageResult of report.packages ?? []) {
for (const dependency of packageResult.dependencies ?? []) {
const candidateVersions = new Set(dependency.candidate_versions ?? [])
const failedAttempts = (dependency.attempts ?? []).filter(
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
)
if (!failedAttempts.length) {
continue
}
const failuresByVersion = new Map()
for (const attempt of failedAttempts) {
const version = attempt.trial_upper || "unknown"
if (!failuresByVersion.has(version)) {
failuresByVersion.set(version, attempt.error || "No error output captured.")
}
}
dependencyFailures.push({
packageName: packageResult.package_name,
projectPath: packageResult.project_path,
dependencyName: dependency.name,
originalRequirements: dependency.original_requirements ?? [],
finalRequirements: dependency.final_requirements ?? [],
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
})
}
}
if (!dependencyFailures.length) {
core.info("No failing dependency candidates found.")
return
}
const owner = context.repo.owner
const repo = context.repo.repo
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
per_page: 100,
})
const openIssueTitles = new Set(
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
)
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
for (const failure of dependencyFailures) {
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
if (openIssueTitles.has(title)) {
core.info(`Issue already exists: ${title}`)
continue
}
const visibleFailures = failure.failedVersions.slice(0, 5)
const omittedCount = failure.failedVersions.length - visibleFailures.length
const failureDetails = visibleFailures
.map(
(entry) =>
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
)
.join("\n\n")
const body = [
"Automated dependency range validation found candidate versions that failed checks.",
"",
`- Package: \`${failure.packageName}\``,
`- Project path: \`${failure.projectPath}\``,
`- Dependency: \`${failure.dependencyName}\``,
`- Original requirements: ${
failure.originalRequirements.length
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
`- Final requirements after run: ${
failure.finalRequirements.length
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
"",
"### Failed versions and errors",
failureDetails,
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
"",
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
].join("\n")
await github.rest.issues.create({
owner,
repo,
title,
body,
})
openIssueTitles.add(title)
core.info(`Created issue: ${title}`)
}
- name: Refresh lockfile
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
if: steps.validate_ranges.outcome == 'success'
run: uv lock --upgrade
working-directory: ./python
- name: Commit and push dependency updates
id: commit_updates
if: steps.validate_ranges.outcome == 'success'
run: |
BRANCH="automation/python-dependency-range-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dependency updates to commit."
exit 0
fi
git commit -m "chore: update dependency ranges"
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
# Only open/update PRs for validated updates to keep automation branches trustworthy.
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dependency-range-updates"
PR_TITLE="Python: chore: update dependency ranges"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
This PR was generated by the dependency range validation workflow.
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
- Updated package dependency bounds
- Refreshed `python/uv.lock` with `uv lock --upgrade`
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
@@ -0,0 +1,91 @@
name: Python - Dev Dependency Upgrade
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
upgrade-dev-dependencies:
name: Upgrade Dev Dependencies
runs-on: ubuntu-latest
env:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Upgrade dev dependencies and validate workspace
run: uv run poe upgrade-dev-dependencies
working-directory: ./python
- name: Commit and push dev dependency updates
id: commit_updates
run: |
BRANCH="automation/python-dev-dependency-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dev dependency updates to commit."
exit 0
fi
git commit -F- <<'EOF'
Python: chore: upgrade dev dependencies
EOF
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
if: steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dev-dependency-updates"
PR_TITLE="Python: chore: upgrade dev dependencies"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
### Motivation and Context
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
### Description
- Ran `uv run poe upgrade-dev-dependencies`
- Refreshed dev dependency pins in workspace `pyproject.toml` files
- Refreshed `python/uv.lock` with `uv lock --upgrade`
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
- [x] All unit tests pass, and I have added new tests where possible
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
@@ -48,9 +48,8 @@ jobs:
os: ${{ runner.os }}
- name: Test with pytest (unit tests only)
run: >
uv run poe all-tests
uv run poe test -A
-m "not integration"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
+3
View File
@@ -76,6 +76,9 @@ jobs:
- name: Run lab tests
run: cd packages/lab && uv run poe test
- name: Run resource-intensive lab tests
run: cd packages/lab && uv run pytest -m "resource_intensive and not integration" --junitxml=test-results-resource-intensive.xml
- name: Run lab lint
run: cd packages/lab && uv run poe lint
+1 -2
View File
@@ -100,9 +100,8 @@ jobs:
os: ${{ runner.os }}
- name: Test with pytest (unit tests only)
run: >
uv run poe all-tests
uv run poe test -A
-m "not integration"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
@@ -46,7 +46,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-01-get-started
@@ -89,7 +89,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents
@@ -126,7 +126,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-03-workflows
@@ -165,7 +165,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-04-hosting
@@ -209,7 +209,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-05-end-to-end
@@ -249,7 +249,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-autogen-migration
@@ -295,7 +295,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-semantic-kernel-migration
@@ -46,7 +46,7 @@ jobs:
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@v1.2.0
uses: MishaKav/pytest-coverage-comment@v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+3 -3
View File
@@ -32,17 +32,17 @@ jobs:
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run all tests with coverage report
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
- name: Check coverage threshold
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: |
python/python-coverage.xml
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
UV_CACHE_DIR: /tmp/.uv-cache
# Unit tests
- name: Run all tests
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
run: uv run poe test -A
working-directory: ./python
# Surface failing tests
+49
View File
@@ -0,0 +1,49 @@
name: Stale issue and PR ping
on:
schedule:
- cron: '0 0 * * *' # Midnight UTC daily
workflow_dispatch:
inputs:
days_threshold:
description: 'Days of silence before pinging the author'
required: false
default: '4'
dry_run:
description: 'Log what would be pinged without taking action'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
concurrency:
group: stale-issue-pr-ping
cancel-in-progress: true
jobs:
ping_stale:
name: "Ping stale issues and PRs"
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: pip install PyGithub==2.6.0
- name: Run stale issue/PR ping
run: python .github/scripts/stale_issue_pr_ping.py
env:
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
+3
View File
@@ -205,6 +205,9 @@ WARP.md
**/memory-bank/
**/projectBrief.md
**/tmpclaude*
# Dependency-bound validation reports
python/scripts/dependency-*-results.json
python/scripts/dependencies/dependency-*-results.json
# Azurite storage emulator files
*/__azurite_db_blob__.json*
+5 -5
View File
@@ -4,8 +4,8 @@ status: accepted
contact: westey-m
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
consulted:
informed:
consulted:
informed:
---
# Agent Run Responses Design
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/user-guide/concepts/streaming/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/api/python/strands.agent.agent/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
@@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|-|-|
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/user-guide/concepts/agents/structured-output/) |
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/api/python/strands.agent.agent/) |
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time |
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|-|-|
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
| AWS (Strands) | Exposes a `stop_reason` property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) class with options that are tied closely to LLM operations. |
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/docs/api/python/strands.types.event_loop/) property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) class with options that are tied closely to LLM operations. |
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
+6 -5
View File
@@ -120,14 +120,14 @@
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.16.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.12.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
@@ -149,6 +149,7 @@
<!-- Symbols -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- Toolset -->
<PackageVersion Include="ReferenceTrimmer" Version="3.4.5" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -12,13 +12,9 @@
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Agents.ObjectModel" />
<PackageReference Include="Microsoft.Agents.ObjectModel.Json" />
<PackageReference Include="Microsoft.Agents.ObjectModel.PowerFx" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -61,6 +61,12 @@ public static class Program
{
Console.WriteLine($"{outputEvent}");
}
if (evt is WorkflowErrorEvent errorEvent)
{
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
Console.WriteLine($"Details: {errorEvent.Exception}");
}
}
}
}
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
/// <summary>
/// A custom executor that uses an AI agent to provide feedback on a slogan.
/// </summary>
internal sealed class FeedbackExecutor : Executor<SloganResult>
[SendsMessage(typeof(FeedbackResult))]
[YieldsOutput(typeof(string))]
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
{
private readonly AIAgent _agent;
private AgentSession? _session;
@@ -14,7 +14,6 @@
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="System.Net.ServerSentEvents" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
<ItemGroup>
@@ -17,7 +17,7 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
{
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic);
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", SanitizeLogValue(topic));
const int MaxReviewAttempts = 3;
const float ApprovalTimeoutHours = 72;
@@ -34,7 +34,7 @@ internal sealed class Tools(ILogger<Tools> logger)
this._logger.LogInformation(
"Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}",
topic,
SanitizeLogValue(topic),
instanceId);
return $"Workflow started with instance ID: {instanceId}";
@@ -45,7 +45,7 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("The instance ID of the workflow to check")] string instanceId,
[Description("Whether to include detailed information")] bool includeDetails = true)
{
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId);
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
// Get the current agent context using the session-static property
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
@@ -54,7 +54,7 @@ internal sealed class Tools(ILogger<Tools> logger)
if (status is null)
{
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId);
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(instanceId));
return new
{
instanceId,
@@ -78,7 +78,16 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
[Description("Feedback to submit")] HumanApprovalResponse feedback)
{
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId);
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback);
}
/// <summary>
/// Sanitizes a user-provided value for safe inclusion in log entries
/// by removing control characters that could be used for log forging.
/// </summary>
private static string SanitizeLogValue(string value) =>
value
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
}
@@ -157,8 +157,8 @@ public sealed class FunctionTriggers
this._logger.LogInformation(
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
conversationId,
cursor ?? "(beginning)");
SanitizeLogValue(conversationId),
SanitizeLogValue(cursor) ?? "(beginning)");
// Check Accept header to determine response format
// text/plain = raw text output (ideal for terminals)
@@ -205,7 +205,7 @@ public sealed class FunctionTriggers
{
if (chunk.Error != null)
{
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error);
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
break;
}
@@ -224,7 +224,7 @@ public sealed class FunctionTriggers
}
catch (OperationCanceledException)
{
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId));
}
return new EmptyResult();
@@ -316,4 +316,20 @@ public sealed class FunctionTriggers
await response.WriteAsync(sb.ToString());
}
/// <summary>
/// Sanitizes a user-provided value for safe inclusion in log entries
/// by removing control characters that could be used for log forging.
/// </summary>
private static string? SanitizeLogValue(string? value)
{
if (value is null)
{
return null;
}
return value
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
}
}
@@ -15,7 +15,6 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -9,10 +9,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenAI" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -4,6 +4,9 @@
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
// The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire.
#pragma warning disable MEAI001 // HostedMcpServerTool, HostedMcpServerToolApprovalMode are experimental
#pragma warning disable OPENAI001 // GetResponsesClient is experimental
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -3,6 +3,8 @@
// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents
// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder.
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
@@ -4,6 +4,8 @@
// Uses Microsoft Agent Framework with Azure AI Foundry.
// Ready for deployment to Foundry Hosted Agent service.
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
using System.ComponentModel;
using System.Globalization;
using System.Text;
+9
View File
@@ -0,0 +1,9 @@
<Project>
<Import Project="../Directory.Build.props" />
<ItemGroup>
<PackageReference Include="ReferenceTrimmer" PrivateAssets="all" IncludeAssets="build;analyzers;buildTransitive" />
</ItemGroup>
</Project>
@@ -16,12 +16,9 @@
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="System.Net.ServerSentEvents" />
<PackageReference Include="System.Net.Http.Json" />
<PackageReference Include="System.Threading.Channels" />
@@ -28,7 +28,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -33,9 +33,17 @@
## v1.0.0-preview.251219.1
- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670))
## v1.0.0-preview.260311.1
### Changed
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file.
## v1.0.0-preview.251204.1
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
@@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
{
CorrelationId = correlationId,
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
Messages = response.Messages
.Where(HasSerializableContent)
.Select(DurableAgentStateMessage.FromChatMessage)
.ToList(),
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
};
}
@@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
Usage = this.Usage?.ToUsageDetails(),
};
}
// Checks whether a ChatMessage has any content that will produce meaningful serialized data.
// Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable.
// Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and
// AdditionalProperties. We keep the message if any base AIContent has annotations or additional
// properties set. NOTE: if AIContent gains new serializable properties in the future, this check
// should be updated accordingly.
private static bool HasSerializableContent(ChatMessage message)
{
return message.Contents.Any(c =>
c.GetType() != typeof(AIContent) ||
c.Annotations?.Count > 0 ||
c.AdditionalProperties?.Count > 0);
}
}
@@ -18,7 +18,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup>
@@ -15,6 +15,10 @@
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
@@ -26,7 +26,7 @@
<PackageReference Include="Microsoft.PowerFx.Interpreter" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="System.CodeDom" />
<PackageReference Include="System.CodeDom" TreatAsUsed="true" />
<PackageReference Include="System.Collections.Immutable" />
</ItemGroup>
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool configureProtocol = HasConfigureProtocolDefined(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
// Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
@@ -97,7 +97,7 @@ internal static class SemanticAnalyzer
return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, configureProtocol,
isPartialClass, derivesFromExecutor, hasManualConfigureProtocol,
classLocation,
handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
@@ -149,7 +149,7 @@ internal static class SemanticAnalyzer
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (first.HasManualConfigureRoutes)
if (first.HasManualConfigureProtocol)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
@@ -212,6 +212,7 @@ internal static class SemanticAnalyzer
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
@@ -241,6 +242,7 @@ internal static class SemanticAnalyzer
isPartialClass,
derivesFromExecutor,
hasManualConfigureProtocol,
baseHasConfigureProtocol,
classLocation,
typeName,
attributeKind));
@@ -321,7 +323,7 @@ internal static class SemanticAnalyzer
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
BaseHasConfigureProtocol: false, // Not relevant for protocol-only
first.BaseHasConfigureProtocol,
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
@@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes.
/// Used by the incremental generator pipeline to capture classes that declare protocol types
/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented).
/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented).
/// </summary>
/// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param>
/// <param name="Namespace">The namespace of the class.</param>
@@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param>
/// <param name="IsPartialClass">Whether the class is declared as partial.</param>
/// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param>
/// <param name="HasManualConfigureRoutes">Whether the class has a manually defined ConfigureRoutes method.</param>
/// <param name="HasManualConfigureProtocol">Whether the class has a manually defined ConfigureProtocol method.</param>
/// <param name="BaseHasConfigureProtocol">Whether a base class already overrides ConfigureProtocol.</param>
/// <param name="ClassLocation">Location info for diagnostics.</param>
/// <param name="TypeName">The fully qualified type name from the attribute.</param>
/// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param>
@@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo(
string ContainingTypeChain,
bool IsPartialClass,
bool DerivesFromExecutor,
bool HasManualConfigureRoutes,
bool HasManualConfigureProtocol,
bool BaseHasConfigureProtocol,
DiagnosticLocationInfo? ClassLocation,
string TypeName,
ProtocolAttributeKind AttributeKind)
@@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo(
/// </summary>
public static ClassProtocolInfo Empty { get; } = new(
string.Empty, null, string.Empty, null, false, string.Empty,
false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
}
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// Uses value-equatable types to support incremental generator caching.
/// </summary>
/// <remarks>
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes)
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol)
/// is extracted here but validated once per class in CombineMethodResults to avoid
/// redundant validation work when a class has multiple handlers.
/// </remarks>
@@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult(
// Class-level facts (used for validation in CombineMethodResults)
bool IsPartialClass,
bool DerivesFromExecutor,
bool HasManualConfigureRoutes,
bool HasManualConfigureProtocol,
// Class location for diagnostics (value-equatable)
DiagnosticLocationInfo? ClassLocation,
@@ -3,25 +3,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class FanInEdgeState
{
private List<PortableMessageEnvelope> _pendingMessages;
private readonly object _syncLock = new();
public FanInEdgeState(FanInEdgeData fanInEdge)
{
this.SourceIds = fanInEdge.SourceIds.ToArray();
this.Unseen = [.. this.SourceIds];
this._pendingMessages = [];
this.PendingMessages = [];
}
public string[] SourceIds { get; }
public HashSet<string> Unseen { get; private set; }
public List<PortableMessageEnvelope> PendingMessages => this._pendingMessages;
public List<PortableMessageEnvelope> PendingMessages { get; private set; }
[JsonConstructor]
public FanInEdgeState(string[] sourceIds, HashSet<string> unseen, List<PortableMessageEnvelope> pendingMessages)
@@ -29,28 +29,35 @@ internal sealed class FanInEdgeState
this.SourceIds = sourceIds;
this.Unseen = unseen;
this._pendingMessages = pendingMessages;
this.PendingMessages = pendingMessages;
}
public IEnumerable<IGrouping<ExecutorIdentity, MessageEnvelope>>? ProcessMessage(string sourceId, MessageEnvelope envelope)
{
this.PendingMessages.Add(new(envelope));
this.Unseen.Remove(sourceId);
List<PortableMessageEnvelope>? takenMessages = null;
if (this.Unseen.Count == 0)
// Serialize concurrent calls from parallel executor tasks during superstep execution.
// NOTE - IMPORTANT: If this ProcessMessage method ever becomes async, replace this lock with an async friendly solution to avoid deadlocks.
lock (this._syncLock)
{
List<PortableMessageEnvelope> takenMessages = Interlocked.Exchange(ref this._pendingMessages, []);
this.Unseen = [.. this.SourceIds];
this.PendingMessages.Add(new(envelope));
this.Unseen.Remove(sourceId);
if (takenMessages.Count == 0)
if (this.Unseen.Count == 0)
{
return null;
takenMessages = this.PendingMessages;
this.PendingMessages = [];
this.Unseen = [.. this.SourceIds];
}
return takenMessages.Select(portable => portable.ToMessageEnvelope())
.GroupBy(keySelector: messageEnvelope => messageEnvelope.Source);
}
return null;
if (takenMessages is null || takenMessages.Count == 0)
{
return null;
}
return takenMessages
.Select(portable => portable.ToMessageEnvelope())
.GroupBy(messageEnvelope => messageEnvelope.Source);
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Provides extension methods for <see cref="CompactionStrategy"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ChatStrategyExtensions
{
/// <summary>
/// Returns an <see cref="IChatReducer"/> that applies this <see cref="CompactionStrategy"/> to reduce a list of messages.
/// </summary>
/// <param name="strategy">The compaction strategy to wrap as an <see cref="IChatReducer"/>.</param>
/// <returns>
/// An <see cref="IChatReducer"/> that, on each call to <see cref="IChatReducer.ReduceAsync"/>, builds a
/// <see cref="CompactionMessageIndex"/> from the supplied messages and applies the strategy's compaction logic,
/// returning the resulting included messages.
/// </returns>
/// <remarks>
/// This allows any <see cref="CompactionStrategy"/> to be used wherever an <see cref="IChatReducer"/> is expected,
/// bridging the compaction pipeline into systems bound to the <c>Microsoft.Extensions.AI</c> <see cref="IChatReducer"/> contract.
/// </remarks>
public static IChatReducer AsChatReducer(this CompactionStrategy strategy)
{
Throw.IfNull(strategy);
return new CompactionStrategyChatReducer(strategy);
}
/// <summary>
/// An <see cref="IChatReducer"/> adapter that delegates to a <see cref="CompactionStrategy"/>.
/// </summary>
private sealed class CompactionStrategyChatReducer : IChatReducer
{
private readonly CompactionStrategy _strategy;
public CompactionStrategyChatReducer(CompactionStrategy strategy)
{
this._strategy = strategy;
}
/// <inheritdoc/>
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
CompactionMessageIndex index = CompactionMessageIndex.Create([.. messages]);
await this._strategy.CompactAsync(index, cancellationToken: cancellationToken).ConfigureAwait(false);
return index.GetIncludedMessages();
}
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -30,6 +31,12 @@ namespace Microsoft.Agents.AI.Compaction;
/// </code>
/// </para>
/// <para>
/// A custom <see cref="ToolCallFormatter"/> can be supplied to override the default YAML-like
/// summary format. The formatter receives the <see cref="CompactionMessageGroup"/> being collapsed
/// and must return the replacement summary string. <see cref="DefaultToolCallFormatter"/> is the
/// built-in default and can be reused inside a custom formatter when needed.
/// </para>
/// <para>
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
/// </para>
@@ -62,7 +69,10 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
public ToolResultCompactionStrategy(
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
@@ -74,6 +84,13 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// </summary>
public int MinimumPreservedGroups { get; }
/// <summary>
/// An optional custom formatter that converts a <see cref="CompactionMessageGroup"/> into a summary string.
/// When <see langword="null"/>, <see cref="DefaultToolCallFormatter"/> is used, which produces a YAML-like
/// block listing each tool name and its results.
/// </summary>
public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
@@ -120,7 +137,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
int idx = eligibleIndices[e] + offset;
CompactionMessageGroup group = index.Groups[idx];
string summary = BuildToolCallSummary(group);
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
@@ -145,14 +162,18 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
}
/// <summary>
/// Builds a concise summary string for a tool call group, including tool names,
/// The default formatter that produces a YAML-like summary of tool call groups, including tool names,
/// results, and deduplication counts for repeated tool names.
/// </summary>
private static string BuildToolCallSummary(CompactionMessageGroup group)
/// <remarks>
/// This is the formatter used when no custom <see cref="ToolCallFormatter"/> is supplied.
/// It can be referenced directly in a custom formatter to augment or wrap the default output.
/// </remarks>
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
{
// Collect function calls (callId, name) and results (callId → result text)
List<(string CallId, string Name)> functionCalls = [];
Dictionary<string, string> resultsByCallId = new();
Dictionary<string, string> resultsByCallId = [];
List<string> plainTextResults = [];
foreach (ChatMessage message in group.Messages)
@@ -187,7 +208,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
// grouping by tool name while preserving first-seen order.
int plainTextIdx = 0;
List<string> orderedNames = [];
Dictionary<string, List<string>> groupedResults = new();
Dictionary<string, List<string>> groupedResults = [];
foreach ((string callId, string name) in functionCalls)
{
@@ -175,15 +175,23 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
try
{
_ = string.Format(optionsInstructions, string.Empty);
promptTemplate = optionsInstructions;
}
catch (FormatException ex)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
"The provided SkillsInstructionPrompt is not a valid format string.",
nameof(options),
ex);
}
if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
nameof(options));
}
promptTemplate = optionsInstructions;
}
if (skills.Count == 0)
@@ -8,7 +8,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
public sealed class DurableAgentStateResponseTests
{
[Fact]
public void FromResponseDropsMessagesContainingOnlyOpaqueContent()
{
// Arrange: one message with real text, one with only opaque AIContent
ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!")
{
CreatedAt = DateTimeOffset.UtcNow
};
ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [
new AIContent
{
RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" }
}])
{
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
};
AgentResponse response = new(new List<ChatMessage> { usefulMessage, opaqueOnlyMessage })
{
CreatedAt = DateTimeOffset.UtcNow
};
// Act
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response);
// Assert: only the useful message survives
DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages);
Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role);
// Round-trip to verify the content is correct
AgentResponse convertedResponse = durableResponse.ToResponse();
ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages);
TextContent textContent = Assert.IsType<TextContent>(Assert.Single(convertedMessage.Contents));
Assert.Equal("Hello, world!", textContent.Text);
}
[Fact]
public void FromResponseKeepsMessagesWithMixedContent()
{
// Arrange: one message with both real text and opaque AIContent
ChatMessage mixedMessage = new(ChatRole.Assistant, [
new TextContent("Some useful text"),
new AIContent { RawRepresentation = new { kind = "metadata" } }])
{
CreatedAt = DateTimeOffset.UtcNow
};
AgentResponse response = new(new List<ChatMessage> { mixedMessage })
{
CreatedAt = DateTimeOffset.UtcNow
};
// Act
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response);
// Assert: the message is kept because it contains at least one serializable content
DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages);
Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role);
}
[Fact]
public void FromResponseDropsAllMessagesWhenAllAreOpaque()
{
// Arrange: all messages contain only opaque AIContent
ChatMessage opaque1 = new(ChatRole.Assistant, [
new AIContent { RawRepresentation = new { kind = "event1" } }])
{
CreatedAt = DateTimeOffset.UtcNow
};
ChatMessage opaque2 = new(ChatRole.Assistant, [
new AIContent { RawRepresentation = new { kind = "event2" } }])
{
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
};
AgentResponse response = new(new List<ChatMessage> { opaque1, opaque2 })
{
CreatedAt = DateTimeOffset.UtcNow
};
// Act
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response);
// Assert: no messages stored
Assert.Empty(durableResponse.Messages);
}
[Fact]
public void FromResponseKeepsBaseAIContentWithAnnotations()
{
// Arrange: base AIContent with annotations should be kept
AIContent contentWithAnnotations = new()
{
RawRepresentation = new { kind = "event" },
Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }]
};
ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations])
{
CreatedAt = DateTimeOffset.UtcNow
};
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
// Act
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response);
// Assert: message is kept because the AIContent has annotations
Assert.Single(durableResponse.Messages);
}
[Fact]
public void FromResponseKeepsBaseAIContentWithAdditionalProperties()
{
// Arrange: base AIContent with additional properties should be kept
AIContent contentWithProps = new()
{
RawRepresentation = new { kind = "event" },
AdditionalProperties = new() { ["custom_key"] = "custom_value" }
};
ChatMessage message = new(ChatRole.Assistant, [contentWithProps])
{
CreatedAt = DateTimeOffset.UtcNow
};
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
// Act
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response);
// Assert: message is kept because the AIContent has additional properties
Assert.Single(durableResponse.Messages);
}
}
@@ -10,7 +10,6 @@
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
@@ -6,7 +6,6 @@
<PropertyGroup>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
</PropertyGroup>
<ItemGroup>
@@ -21,6 +21,12 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
private const string RedisPort = "6379";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
#if DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
private static readonly HttpClient s_sharedHttpClient = new();
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
@@ -825,7 +831,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
ProcessStartInfo buildInfo = new()
{
FileName = "dotnet",
Arguments = $"build -f {s_dotnetTargetFramework}",
Arguments = $"build -f {s_dotnetTargetFramework} -c {BuildConfiguration}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
@@ -855,7 +861,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --no-build -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
@@ -20,6 +20,12 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
private const string DtsPort = "8080";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
#if DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
private static readonly HttpClient s_sharedHttpClient = new();
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
@@ -437,7 +443,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
Arguments = $"run -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
@@ -127,6 +127,42 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
Assert.Equal("options", ex.ParamName);
}
[Fact]
public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
{
// Arrange -- valid format string but missing the required placeholder
var options = new FileAgentSkillsProviderOptions
{
SkillsInstructionPrompt = "No placeholder here"
};
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
Assert.Contains("{0}", ex.Message);
Assert.Equal("options", ex.ParamName);
}
[Fact]
public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
{
// Arrange — valid custom template with {0} placeholder
this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
var options = new FileAgentSkillsProviderOptions
{
SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
};
var provider = new FileAgentSkillsProvider(this._testRoot, options);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — the custom template wraps the skill list
Assert.NotNull(result.Instructions);
Assert.StartsWith("== Skills ==", result.Instructions);
Assert.Contains("custom-tpl-skill", result.Instructions);
Assert.Contains("== End ==", result.Instructions);
}
[Fact]
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
{
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ChatStrategyExtensions"/> class.
/// </summary>
public class ChatStrategyExtensionsTests
{
[Fact]
public void AsChatReducerNullStrategyThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => ((CompactionStrategy)null!).AsChatReducer());
}
[Fact]
public void AsChatReducerReturnsIChatReducer()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
// Act
IChatReducer reducer = strategy.AsChatReducer();
// Assert
Assert.NotNull(reducer);
}
[Fact]
public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync()
{
// Arrange — trigger never fires, so no compaction occurs
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi!"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
Assert.Equal(messages, result);
}
[Fact]
public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync()
{
// Arrange — reducer keeps only the last message
ChatReducerCompactionStrategy strategy = new(
new TakeLastReducer(1),
CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Response 1"),
new(ChatRole.User, "Second"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
List<ChatMessage> resultList = [.. result];
Assert.Single(resultList);
Assert.Equal("Second", resultList[0].Text);
}
[Fact]
public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync()
{
// Arrange
using CancellationTokenSource cts = new();
CancellationToken capturedToken = default;
CapturingReducer capturingReducer = new(token => capturedToken = token);
ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.User, "World"),
];
// Act
await reducer.ReduceAsync(messages, cts.Token);
// Assert
Assert.Equal(cts.Token, capturedToken);
}
[Fact]
public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync([], CancellationToken.None);
// Assert
Assert.Empty(result);
}
/// <summary>
/// An <see cref="IChatReducer"/> that returns messages unchanged.
/// </summary>
private sealed class IdentityReducer : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
/// <summary>
/// An <see cref="IChatReducer"/> that keeps only the last <c>n</c> messages.
/// </summary>
private sealed class TakeLastReducer : IChatReducer
{
private readonly int _count;
public TakeLastReducer(int count) => this._count = count;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages.Reverse().Take(this._count));
}
/// <summary>
/// An <see cref="IChatReducer"/> that captures the <see cref="CancellationToken"/> passed to <see cref="ReduceAsync"/>.
/// </summary>
private sealed class CapturingReducer : IChatReducer
{
private readonly Action<CancellationToken> _capture;
public CapturingReducer(Action<CancellationToken> capture) => this._capture = capture;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this._capture(cancellationToken);
IEnumerable<ChatMessage> reducedMessages = [messages.Reverse().First()];
return Task.FromResult(reducedMessages);
}
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
@@ -348,4 +349,90 @@ public class ToolResultCompactionStrategyTests
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncUsesCustomFormatterAsync()
{
// Arrange — custom formatter that produces a collapsed message count
static string CustomFormatter(CompactionMessageGroup group) =>
$"[Collapsed: {group.Messages.Count} messages]";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = CustomFormatter,
};
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — custom formatter output used instead of default YAML-like format
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
}
[Fact]
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
{
// Arrange
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);
// Assert — ToolCallFormatter is null when no custom formatter is provided
Assert.Null(strategy.ToolCallFormatter);
}
[Fact]
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
{
// Arrange
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.Always)
{
ToolCallFormatter = customFormatter
};
// Assert — ToolCallFormatter is the injected custom function
Assert.Same(customFormatter, strategy.ToolCallFormatter);
}
[Fact]
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
{
// Arrange — custom formatter that wraps the default output
static string WrappingFormatter(CompactionMessageGroup group) =>
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = WrappingFormatter
};
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — wrapped default output
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
}
}
@@ -16,7 +16,6 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
@@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests
}
[Fact]
public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides()
public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides()
{
// File 1: Partial with one handler
var file1 = """
@@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests
generated.Should().RegisterSentMessageType("string")
.And.RegisterSentMessageType("int")
.And.RegisterYieldedOutputType("string")
.And.RegisterYieldedOutputType("string");
.And.RegisterYieldedOutputType("int");
}
#endregion
@@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
}
[Fact]
public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall()
{
// A protocol-only partial executor deriving from Executor<T>
// has a base class that already overrides ConfigureProtocol. The generator must emit
// "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations
// are preserved — not "return protocolBuilder" which silently drops them.
var source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
namespace TestNamespace;
public class FeedbackResult { }
[SendsMessage(typeof(FeedbackResult))]
[YieldsOutput(typeof(string))]
public partial class FeedbackExecutor : Executor<string>
{
public FeedbackExecutor() : base("feedback") { }
public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default)
=> default;
}
""";
var result = GeneratorTestHelper.RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
// Base class Executor<T> overrides ConfigureProtocol, so the generated override
// must chain to base to preserve the inherited handler registration.
generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)",
because: "Executor<T> overrides ConfigureProtocol, so base must be called to preserve its handler registration");
generated.Should().Contain(".SendsMessage<global::TestNamespace.FeedbackResult>()");
generated.Should().Contain(".YieldsOutput<string>()");
}
[Fact]
public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall()
{
// A protocol-only partial executor deriving directly from Executor (abstract base
// with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder"
// rather than "return base.ConfigureProtocol(protocolBuilder)".
var source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
namespace TestNamespace;
public class BroadcastMessage { }
[SendsMessage(typeof(BroadcastMessage))]
public partial class BroadcastExecutor : Executor
{
public BroadcastExecutor() : base("broadcast") { }
}
""";
var result = GeneratorTestHelper.RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
// Executor's ConfigureProtocol is abstract — no base call needed.
generated.Should().Contain("return protocolBuilder",
because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed");
generated.Should().NotContain("base.ConfigureProtocol");
}
#endregion
#region Generic Executor Tests
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -199,4 +200,43 @@ public class EdgeRunnerTests
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
}
}
[Fact]
public async Task Test_FanInEdgeRunner_ConcurrentProcessingAsync()
{
// Arrange
const int SourceCount = 4;
const int Iterations = 50;
string[] sourceIds = Enumerable.Range(0, SourceCount).Select(i => $"source{i}").ToArray();
const string SinkId = "sink";
TestRunContext runContext = new();
List<Executor> executors = [.. sourceIds.Select(id => (Executor)new ForwardMessageExecutor<string>(id)), new ForwardMessageExecutor<string>(SinkId)];
runContext.ConfigureExecutors(executors);
FanInEdgeData edgeData = new(sourceIds.ToList(), SinkId, new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
for (int iteration = 0; iteration < Iterations; iteration++)
{
// Act: send messages from all sources concurrently
using Barrier barrier = new(SourceCount);
Task<DeliveryMapping?>[] tasks = sourceIds.Select(sourceId => Task.Run(async () =>
{
barrier.SignalAndWait();
return await runner.ChaseEdgeAsync(new($"msg-from-{sourceId}", sourceId), stepTracer: null, CancellationToken.None);
})).ToArray();
DeliveryMapping?[] results = await Task.WhenAll(tasks);
// Assert: exactly one task should return a non-null mapping with all messages
DeliveryMapping?[] nonNullResults = results.Where(r => r is not null).ToArray();
nonNullResults.Should().HaveCount(1, $"iteration {iteration}: exactly one thread should release the batch");
DeliveryMapping mapping = nonNullResults[0]!;
HashSet<object> expectedMessages = [.. sourceIds.Select(id => (object)$"msg-from-{id}")];
mapping.CheckDeliveries([SinkId], expectedMessages);
}
}
}
@@ -17,7 +17,7 @@ public class MessageMergerTests
[Fact]
public void Test_MessageMerger_AssemblesMessage()
{
DateTimeOffset creationTime = DateTimeOffset.UtcNow;
DateTimeOffset creationTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1));
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
+26 -16
View File
@@ -13,26 +13,34 @@ description: >
All commands run from the `python/` directory:
```bash
# Format code (ruff format, parallel across packages)
uv run poe fmt
# Lint and auto-fix (ruff check, parallel across packages)
uv run poe lint
# Syntax formatting + checks (parallel across packages by default)
uv run poe syntax
uv run poe syntax -P core
uv run poe syntax -F # Format only
uv run poe syntax -C # Check only
uv run poe syntax -S # Samples only
# Type checking
uv run poe pyright # Pyright (parallel across packages)
uv run poe mypy # MyPy (parallel across packages)
uv run poe pyright # Pyright fan-out across packages
uv run poe pyright -P core
uv run poe pyright -A
uv run poe mypy # MyPy fan-out across packages
uv run poe mypy -P core
uv run poe mypy -A
uv run poe typing # Both pyright and mypy
uv run poe typing -P core
uv run poe typing -A
# All package-level checks in parallel (fmt + lint + pyright + mypy)
# All package-level checks in parallel (syntax + pyright)
uv run poe check-packages
# Full check (packages + samples + tests + markdown)
uv run poe check
uv run poe check -P core
# Samples only
uv run poe samples-lint # Ruff lint on samples/
uv run poe samples-syntax # Pyright syntax check on samples/
uv run poe check -S
uv run poe pyright -S
# Markdown code blocks
uv run poe markdown-code-lint
@@ -40,8 +48,8 @@ uv run poe markdown-code-lint
## Pre-commit Hooks (prek)
Prek hooks run automatically on commit. They check only changed files and run
package-level checks in parallel for affected packages only.
Prek hooks run automatically on commit. They stay lightweight and only check
changed files.
```bash
# Install hooks
@@ -54,8 +62,10 @@ uv run prek run -a
uv run prek run --last-commit
```
When core package changes, type-checking (mypy, pyright) runs across all packages
since type changes propagate. Format and lint only run in changed packages.
They run changed-package syntax formatting/checking, markdown code lint only
when markdown files change, and sample syntax lint/pyright only when files
under `samples/` change.
They intentionally do not run workspace `pyright` or `mypy` by default.
## Ruff Configuration
@@ -80,6 +90,6 @@ in-process with streaming output.
CI splits into 4 parallel jobs:
1. **Pre-commit hooks** — lightweight hooks (SKIP=poe-check)
2. **Package checks** — fmt/lint/pyright via check-packages
3. **Samples & markdown** — samples-lint, samples-syntax, markdown-code-lint
2. **Package checks** — syntax/pyright via check-packages
3. **Samples & markdown** — `check -S` plus `markdown-code-lint`
4. **Mypy** — change-detected mypy checks
+4 -4
View File
@@ -69,7 +69,7 @@ def equal(arg1: str, arg2: str) -> bool:
```python
# Core
from agent_framework import ChatAgent, Message, tool
from agent_framework import Agent, Message, tool
# Components
from agent_framework.observability import enable_instrumentation
@@ -82,16 +82,16 @@ from agent_framework.azure import AzureOpenAIChatClient
## Public API and Exports
In `__init__.py` files that define package-level public APIs, use direct re-export imports plus an explicit
`__all__`. Avoid identity aliases like `from ._agents import ChatAgent as ChatAgent`, and avoid
`__all__`. Avoid identity aliases like `from ._agents import Agent as Agent`, and avoid
`from module import *`.
Do not define `__all__` in internal non-`__init__.py` modules. Exception: modules intentionally exposed as a
public import surface (for example, `agent_framework.observability`) should define `__all__`.
```python
__all__ = ["ChatAgent", "Message", "ChatResponse"]
__all__ = ["Agent", "Message", "ChatResponse"]
from ._agents import ChatAgent
from ._agents import Agent
from ._types import Message, ChatResponse
```
+43 -1
View File
@@ -33,13 +33,44 @@ Uses [uv](https://github.com/astral-sh/uv) for dependency management and
# Full setup (venv + install + prek hooks)
uv run poe setup
# Install/update all dependencies
# Install dependencies from lockfile (frozen resolution with prerelease policy)
uv run poe install
# Create venv with specific Python version
uv run poe venv --python 3.12
# Intentionally upgrade a specific dependency to reduce lockfile conflicts
uv lock --upgrade-package <dependency-name> && uv run poe install
# Refresh all dev dependency pins, lockfile, and validation in one run
uv run poe upgrade-dev-dependencies
# First, run workspace-wide lower/upper compatibility gates
uv run poe validate-dependency-bounds-test
# Defaults to --package "*"; pass a package to scope test mode
uv run poe validate-dependency-bounds-test --package core
# Then expand bounds for one dependency in the target package
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
# Repo-wide automation can reuse the same task
uv run poe validate-dependency-bounds-project --mode upper --package "*"
# Add a dependency to one project and run both validators for that project/dependency
uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"
```
### Dependency Bound Notes
- Stable dependencies (`>=1.0`) should typically be bounded as `>=<known-good>,<next-major>`.
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges).
- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible.
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
- Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.
## Lazy Loading Pattern
Provider folders in core use `__getattr__` to lazy load from connector packages:
@@ -74,6 +105,17 @@ def __getattr__(name: str) -> Any:
4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml`
5. Do **NOT** create lazy loading in core yet
Recommended dependency workflow during connector implementation:
1. Add the dependency to the target package:
`uv run poe add-dependency-to-project --package core --dependency "<dependency-spec>"`
2. Implement connector code and tests.
3. Validate dependency bounds for that package/dependency:
`uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"`
4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command:
`uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"`
If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation.
### Promotion to Stable
1. Move samples to root `samples/` folder
+7 -4
View File
@@ -41,11 +41,14 @@ Do **not** add sample-only dependencies to the root `pyproject.toml` dev group.
## Syntax Checking
```bash
# Check samples for syntax errors and missing imports
uv run poe samples-syntax
# Format + lint samples
uv run poe syntax -S
# Lint samples
uv run poe samples-lint
# Check samples for syntax errors and missing imports
uv run poe pyright -S
# Lint samples only
uv run poe syntax -S -C
```
## Documentation
+15 -8
View File
@@ -17,20 +17,27 @@ We run tests in two stages, for a PR each commit is tested with unit tests only
# Run tests for all packages in parallel
uv run poe test
# Run tests for a specific package
uv run --directory packages/core poe test
# Run tests for a specific workspace package
uv run poe test -P core
# Run all tests in a single pytest invocation (faster, uses pytest-xdist)
uv run poe all-tests
# Run all selected tests in a single pytest invocation
uv run poe test -A
# With coverage
uv run poe all-tests-cov
uv run poe test -A -C
uv run poe test -P core -C
# Run only unit tests (exclude integration tests)
uv run poe all-tests -m "not integration"
uv run poe test -A -m "not integration"
# Run only integration tests
uv run poe all-tests -m integration
uv run poe test -A -m integration
```
Direct package execution still works when you need it:
```bash
uv run --directory packages/core poe test
```
## Test Configuration
@@ -38,7 +45,7 @@ uv run poe all-tests -m integration
- **Async mode**: `asyncio_mode = "auto"` is enabled — do NOT use `@pytest.mark.asyncio`, but do mark tests with `async def` and use `await` for async calls
- **Timeout**: Default 60 seconds per test
- **Import mode**: `importlib` for cross-package isolation
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The `all-tests` task also uses xdist across all packages.
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The aggregate `uv run poe test -A` sweep also uses xdist across the selected packages.
## Test Directory Structure
+3 -3
View File
@@ -52,10 +52,10 @@ repos:
hooks:
- id: poe-check
name: Run checks through Poe
entry: uv run poe prek-check
entry: uv run python scripts/workspace_poe_tasks.py prek-check
language: system
- repo: https://github.com/PyCQA/bandit
rev: 1.9.3
rev: 1.9.4
hooks:
- id: bandit
name: Bandit Security Checks
@@ -63,7 +63,7 @@ repos:
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.10.0
rev: 0.10.10
hooks:
# Update the uv lockfile
- id: uv-lock
+49 -12
View File
@@ -9,9 +9,8 @@
"command": "uv",
"args": [
"run",
"prek",
"run",
"-a"
"poe",
"check"
],
"problemMatcher": {
"owner": "python",
@@ -32,13 +31,13 @@
}
},
{
"label": "Format",
"label": "Syntax",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"fmt",
"syntax",
],
"problemMatcher": {
"owner": "python",
@@ -59,13 +58,42 @@
}
},
{
"label": "Lint",
"label": "Syntax (format only)",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"lint",
"syntax",
"-F",
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Syntax (check only)",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"syntax",
"-C",
],
"problemMatcher": {
"owner": "python",
@@ -169,7 +197,14 @@
{
"label": "Create Venv",
"type": "shell",
"command": "uv venv PYTHON=${input:py_version}",
"command": "uv",
"args": [
"run",
"poe",
"venv",
"-P",
"${input:py_version}"
],
"presentation": {
"reveal": "always",
"panel": "new"
@@ -184,7 +219,8 @@
"run",
"poe",
"setup",
"--python=${input:py_version}"
"-P",
"${input:py_version}"
],
"presentation": {
"reveal": "always",
@@ -200,11 +236,12 @@
"3.10",
"3.11",
"3.12",
"3.13"
"3.13",
"3.14"
],
"id": "py_version",
"description": "Python version",
"default": "3.10"
"default": "3.13"
}
]
}
}
+51 -1
View File
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0rc5] - 2026-03-19
### Added
- **samples**: Add foundry hosted agents samples for python ([#4648](https://github.com/microsoft/agent-framework/pull/4648))
- **repo**: Add automated stale issue and PR follow-up ping workflow ([#4776](https://github.com/microsoft/agent-framework/pull/4776))
- **agent-framework-ag-ui**: Emit AG-UI events for MCP tool calls, results, and text reasoning ([#4760](https://github.com/microsoft/agent-framework/pull/4760))
- **agent-framework-ag-ui**: Emit TOOL_CALL_RESULT events when resuming after tool approval ([#4758](https://github.com/microsoft/agent-framework/pull/4758))
### Changed
- **agent-framework-devui**: Bump minimatch from 3.1.2 to 3.1.5 in frontend ([#4337](https://github.com/microsoft/agent-framework/pull/4337))
- **agent-framework-devui**: Bump rollup from 4.47.1 to 4.59.0 in frontend ([#4338](https://github.com/microsoft/agent-framework/pull/4338))
- **agent-framework-core**: Unify tool results as `Content` items with rich content support ([#4331](https://github.com/microsoft/agent-framework/pull/4331))
- **agent-framework-a2a**: Default `A2AAgent` name and description from `AgentCard` ([#4661](https://github.com/microsoft/agent-framework/pull/4661))
- **agent-framework-core**: [BREAKING] Clean up kwargs across agents, chat clients, tools, and sessions ([#4581](https://github.com/microsoft/agent-framework/pull/4581))
- **agent-framework-devui**: Bump tar from 7.5.9 to 7.5.11 ([#4688](https://github.com/microsoft/agent-framework/pull/4688))
- **repo**: Improve Python dependency range automation ([#4343](https://github.com/microsoft/agent-framework/pull/4343))
- **agent-framework-core**: Normalize empty MCP tool output to `null` ([#4683](https://github.com/microsoft/agent-framework/pull/4683))
- **agent-framework-core**: Remove bad dependency ([#4696](https://github.com/microsoft/agent-framework/pull/4696))
- **agent-framework-core**: Keep MCP cleanup on the owner task ([#4687](https://github.com/microsoft/agent-framework/pull/4687))
- **agent-framework-a2a**: Preserve A2A message `context_id` ([#4686](https://github.com/microsoft/agent-framework/pull/4686))
- **repo**: Bump `danielpalme/ReportGenerator-GitHub-Action` from 5.5.1 to 5.5.3 ([#4542](https://github.com/microsoft/agent-framework/pull/4542))
- **repo**: Bump `MishaKav/pytest-coverage-comment` from 1.2.0 to 1.6.0 ([#4543](https://github.com/microsoft/agent-framework/pull/4543))
- **agent-framework-core**: Bump `pyjwt` from 2.11.0 to 2.12.0 ([#4699](https://github.com/microsoft/agent-framework/pull/4699))
- **agent-framework-azure-ai**: Reduce Azure chat client import overhead ([#4744](https://github.com/microsoft/agent-framework/pull/4744))
- **repo**: Simplify Python Poe tasks and unify package selectors ([#4722](https://github.com/microsoft/agent-framework/pull/4722))
- **agent-framework-core**: Aggregate token usage across tool-call loop iterations in `invoke_agent` span ([#4739](https://github.com/microsoft/agent-framework/pull/4739))
- **agent-framework-core**: Support `detail` field in OpenAI Chat API `image_url` payload ([#4756](https://github.com/microsoft/agent-framework/pull/4756))
- **agent-framework-anthropic**: [BREAKING] Refactor middleware layering and split Anthropic raw client ([#4746](https://github.com/microsoft/agent-framework/pull/4746))
- **agent-framework-github-copilot**: Emit tool call events in GitHubCopilotAgent streaming ([4711](https://github.com/microsoft/agent-framework/pull/4711))
### Fixed
- **agent-framework-core**: Validate approval responses against the server-side pending request registry ([#4548](https://github.com/microsoft/agent-framework/pull/4548))
- **agent-framework-devui**: Validate function approval responses in the DevUI executor ([#4598](https://github.com/microsoft/agent-framework/pull/4598))
- **agent-framework-azurefunctions**: Use `deepcopy` for state snapshots so nested mutations are detected in durable workflow activities ([#4518](https://github.com/microsoft/agent-framework/pull/4518))
- **agent-framework-bedrock**: Fix `BedrockChatClient` sending invalid toolChoice `"none"` to the Bedrock API ([#4535](https://github.com/microsoft/agent-framework/pull/4535))
- **agent-framework-core**: Fix type hint for `Case` and `Default` ([#3985](https://github.com/microsoft/agent-framework/pull/3985))
- **agent-framework-core**: Fix duplicate tool names between supplied tools and MCP servers ([#4649](https://github.com/microsoft/agent-framework/pull/4649))
- **agent-framework-core**: Fix `_deduplicate_messages` catch-all branch dropping valid repeated messages ([#4716](https://github.com/microsoft/agent-framework/pull/4716))
- **samples**: Fix Azure Redis sample missing session for history persistence ([#4692](https://github.com/microsoft/agent-framework/pull/4692))
- **agent-framework-core**: Fix thread serialization for multi-turn tool calls ([#4684](https://github.com/microsoft/agent-framework/pull/4684))
- **agent-framework-core**: Fix `RUN_FINISHED.interrupt` to accumulate all interrupts when multiple tools need approval ([#4717](https://github.com/microsoft/agent-framework/pull/4717))
- **agent-framework-azurefunctions**: Fix missing methods on the `Content` class in durable tasks ([#4738](https://github.com/microsoft/agent-framework/pull/4738))
- **agent-framework-core**: Fix `ENABLE_SENSITIVE_DATA` being ignored when set after module import ([#4743](https://github.com/microsoft/agent-framework/pull/4743))
- **agent-framework-a2a**: Fix `A2AAgent` to invoke context providers before and after run ([#4757](https://github.com/microsoft/agent-framework/pull/4757))
- **agent-framework-core**: Fix MCP tool schema normalization for zero-argument tools missing the `properties` key ([#4771](https://github.com/microsoft/agent-framework/pull/4771))
## [1.0.0rc4] - 2026-03-11
### Added
@@ -768,7 +817,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...HEAD
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
[1.0.0rc3]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...python-1.0.0rc3
[1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2
+25 -3
View File
@@ -127,7 +127,12 @@ def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | Cha
Avoid `**kwargs` unless absolutely necessary. It should only be used as an escape route, not for well-known flows of data:
- **Prefer named parameters**: If there are known extra arguments being passed, use explicit named parameters instead of kwargs
- **Prefer purpose-specific buckets over generic kwargs**: If a flexible payload is still needed, use an explicit named parameter such as `additional_properties`, `function_invocation_kwargs`, or `client_kwargs` rather than a blanket `**kwargs`
- **Subclassing support**: kwargs is acceptable in methods that are part of classes designed for subclassing, allowing subclass-defined kwargs to pass through without issues. In this case, clearly document that kwargs exists for subclass extensibility and not for passing arbitrary data
- **Make known flows explicit first**: For abstract hooks, move known data flows into explicit parameters before leaving `**kwargs` behind for subclass extensibility (for example, prefer `state=` explicitly instead of passing it through kwargs)
- **Prefer explicit metadata containers**: For constructors that expose metadata, prefer an explicit `additional_properties` parameter.
- **Keep SDK passthroughs narrow and documented**: A kwargs escape hatch may be acceptable for provider helper APIs that pass through to a large or unstable external SDK surface, but it should be documented as SDK passthrough and revisited regularly
- **Do not keep passthrough kwargs on wrappers that do not use them**: Convenience wrappers and session helpers should not accept generic kwargs merely to forward or ignore them
- **Remove when possible**: In other cases, removing kwargs is likely better than keeping it
- **Separate kwargs by purpose**: When combining kwargs for multiple purposes, use specific parameters like `client_kwargs: dict[str, Any]` instead of mixing everything in `**kwargs`
- **Always document**: If kwargs must be used, always document how it's used, either by referencing external documentation or explaining its purpose
@@ -160,10 +165,14 @@ user_msg = Message("user", ["Hello, world!"])
asst_msg = Message("assistant", ["Hello, world!"])
# ❌ Not preferred - unnecessary inheritance
from agent_framework import UserMessage, AssistantMessage
class UserMessage(Message):
pass
user_msg = UserMessage(content="Hello, world!")
asst_msg = AssistantMessage(content="Hello, world!")
class AssistantMessage(Message):
pass
user_msg = UserMessage("user", ["Hello, world!"])
asst_msg = AssistantMessage("assistant", ["Hello, world!"])
```
### Import Structure
@@ -383,6 +392,19 @@ All non-core packages declare a lower bound on `agent-framework-core` (e.g., `"a
- **Core version changes**: When `agent-framework-core` is updated with breaking or significant changes and its version is bumped, update the `agent-framework-core>=...` lower bound in every other package's `pyproject.toml` to match the new core version.
- **Non-core version changes**: Non-core packages (connectors, extensions) can have their own versions incremented independently while keeping the existing core lower bound pinned. Only raise the core lower bound if the non-core package actually depends on new core APIs.
### External Dependency Version Bounds
The guiding principle for external dependencies is to make the range of allowed versions as broad as possible, even if that means we have to do some conditional imports, and other tricks to allow small changes in versions.
So we use bounded ranges for external package dependencies in `pyproject.toml`:
- For stable dependencies (`>=1.0.0`), use a lower bound at a known-good version and an explicit upper bound that reflects the maximum major version we currently support (for example: `openai>=1.99.0,<3`).
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good lower bound with a hard upper boundary in the same prerelease line (for example: `azure-ai-projects>=2.0.0b3,<2.0.0b4`).
- For `<1.0.0` dependencies, use a known-good bounded range with an explicit upper cap. Prefer the broadest validated range the package can actually support: that may be a patch line, a minor line, or multiple minor lines (for example: `a2a-sdk>=0.3.5,<0.4.0`, `fastapi>=0.115.0,<0.136.0`, `uvicorn>=0.30.0,<0.39.0`).
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good bounded range with a hard upper cap and keep the range only as broad as the package's validation coverage justifies.
- Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility.
- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --package <workspace-package-name> --dependency "<dependency-name>"` to expand package-scoped bounds.
### Installation Options
Connectors are distributed as separate packages and are not imported by default in the core package. Users install the specific connectors they need:
+135 -60
View File
@@ -123,28 +123,39 @@ client = OpenAIChatClient(env_file_path="openai.env")
All the tests are located in the `tests` folder of each package. Tests marked with `@pytest.mark.integration` and `@skip_if_..._integration_tests_disabled` are integration tests that require external services (e.g., OpenAI, Azure OpenAI). They are automatically skipped when the required API keys or service endpoints are not configured in your environment or `.env` file.
You can select or exclude integration tests using pytest markers:
The root `test` command now supports both project-scoped fan-out and a single aggregate sweep:
```bash
# Run only unit tests (exclude integration tests)
uv run poe all-tests -m "not integration"
# Run package-local tests across all workspace packages
uv run poe test
# Run only integration tests
uv run poe all-tests -m integration
# Run tests for one workspace package
uv run poe test -P core
# Run an aggregate pytest sweep across the selected packages
uv run poe test -A
# Run only unit tests in aggregate mode
uv run poe test -A -m "not integration"
# Run only integration tests in aggregate mode
uv run poe test -A -m integration
# Run tests with coverage for one package or an aggregate sweep
uv run poe test -P core -C
uv run poe test -A -C
```
Alternatively, you can run them using VSCode Tasks. Open the command palette
(`Ctrl+Shift+P`) and type `Tasks: Run Task`. Select `Test` from the list.
If you want to run the tests for a single package, you can use the `uv run poe test` command with the package name as an argument. For example, to run the tests for the `agent_framework` package, you can use:
Direct package execution still works when you need it:
```bash
uv run poe --directory packages/core test
```
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The `all-tests` task also uses xdist across all packages.
These commands also output the coverage report.
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages.
## Code quality checks
@@ -158,10 +169,11 @@ Ideally you should run these checks before committing any changes, when you inst
## Code Coverage
We try to maintain a high code coverage for the project. To run the code coverage on the unit tests, you can use the following command:
We try to maintain a high code coverage for the project. To review coverage locally, use either a package-scoped run or the aggregate sweep:
```bash
uv run poe test
uv run poe test -P core -C
uv run poe test -A -C
```
This will show you which files are not covered by the tests, including the specific lines not covered. Make sure to consider the untested lines from the code you are working on, but feel free to add other tests as well, that is always welcome!
@@ -213,21 +225,24 @@ Set up the development environment with a virtual environment, install dependenc
```bash
uv run poe setup
# or with specific Python version
uv run poe setup --python 3.12
uv run poe setup -P 3.12
```
#### `install`
Install all dependencies including extras and dev dependencies, including updates:
Install all dependencies (including extras and dev dependencies) from the lockfile using frozen resolution:
```bash
uv run poe install
```
For intentional dependency upgrades, run `uv lock --upgrade-package <dependency-name>` and then run `uv run poe install`.
For repo-wide dev tooling refreshes, run `uv run poe upgrade-dev-dependencies` to repin dev dependencies, refresh `uv.lock`, and rerun validation, typing, and tests.
#### `venv`
Create a virtual environment with specified Python version or switch python version:
```bash
uv run poe venv
# or with specific Python version
uv run poe venv --python 3.12
uv run poe venv -P 3.12
```
#### `prek-install`
@@ -236,41 +251,89 @@ Install prek hooks:
uv run poe prek-install
```
### Code Quality and Formatting
### Project-scoped command families
Each of the following tasks run against both the main `agent-framework` package and the extension packages in parallel, ensuring consistent code quality across the project.
These commands default to `--package "*"`, so they run across all workspace packages unless you narrow them with `-P/--package`:
#### `fmt` (format)
Format code using ruff (runs in parallel across all packages):
#### `syntax`
Run Ruff formatting plus Ruff lint checks by default:
```bash
uv run poe fmt
uv run poe syntax
uv run poe syntax -P core
uv run poe syntax -F # format only
uv run poe syntax -C # lint/check only
```
#### `lint`
Run linting checks and fix issues (runs in parallel across all packages):
#### `build`
Build workspace packages and the root meta package:
```bash
uv run poe lint
uv run poe build
uv run poe build -P core
```
#### `clean-dist`
Clean generated dist artifacts:
```bash
uv run poe clean-dist
uv run poe clean-dist -P core
```
### Dual-mode validation and test commands
These command families share the same selector model:
```bash
uv run poe <command> # project fan-out over --package "*"
uv run poe <command> -P core # one-project fan-out
uv run poe <command> -A # aggregate sweep where supported
```
#### `pyright`
Run Pyright type checking (runs in parallel across all packages):
Run Pyright type checking:
```bash
uv run poe pyright
uv run poe pyright -P core
uv run poe pyright -A
```
#### `mypy`
Run MyPy type checking (runs in parallel across all packages):
Run MyPy type checking:
```bash
uv run poe mypy
uv run poe mypy -P core
uv run poe mypy -A
```
#### `typing`
Run both Pyright and MyPy type checking:
Run both Pyright and MyPy:
```bash
uv run poe typing
uv run poe typing -P core
uv run poe typing -A
```
### Code Validation
#### `test`
Run package-local tests in fan-out mode, or switch to one aggregate pytest sweep with `-A`:
```bash
uv run poe test
uv run poe test -P core
uv run poe test -P core -C
uv run poe test -A
uv run poe test -A -C
```
### Sample-target variants
Use `-S/--samples` for sample-only validation instead of separate top-level commands:
```bash
uv run poe syntax -S
uv run poe syntax -S -C
uv run poe pyright -S
uv run poe check -S
```
### Workspace validation and dependency commands
#### `markdown-code-lint`
Lint markdown code blocks:
@@ -278,72 +341,84 @@ Lint markdown code blocks:
uv run poe markdown-code-lint
```
### Comprehensive Checks
#### `check-packages`
Run all package-level quality checks (format, lint, pyright, mypy) in parallel across all packages. This runs the full cross-product of (package Ă— check) concurrently:
Run the package-level syntax sweep (`syntax`) plus `pyright` across the selected projects:
```bash
uv run poe check-packages
uv run poe check-packages -P core
```
#### `check`
Run all quality checks including package checks, samples, tests and markdown lint:
Run package syntax, pyright, and tests for the selected project set. Without `-P/--package`, it also includes sample checks and markdown lint:
```bash
uv run poe check
uv run poe check -P core
uv run poe check -S
```
### Testing
#### `test`
Run unit tests with coverage by invoking the `test` task in each package in parallel:
#### `validate-dependency-bounds-test`
Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure:
```bash
uv run poe test
uv run poe validate-dependency-bounds-test
# Defaults to --package "*"; pass a package to scope test mode
uv run poe validate-dependency-bounds-test -P core
```
To run tests for a specific package only, use the `--directory` flag:
#### `validate-dependency-bounds-project`
Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`:
```bash
# Run tests for the core package
uv run --directory packages/core poe test
# Run tests for the azure-ai package
uv run --directory packages/azure-ai poe test
uv run poe validate-dependency-bounds-project -M both -P core -D "<dependency-name>"
```
`--package` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --package "*"` to run the upper-bound pass across the workspace.
For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may still be a single patch or minor line, but multi-minor ranges are fine when the package's checks/tests prove they work.
#### `all-tests`
Run all tests in a single pytest invocation across all packages in parallel (excluding lab and devui). This is faster than `test` as it uses pytest's parallel execution:
#### `add-dependency-and-validate-bounds`
Add an external dependency to a workspace project and run both validators for that same project/dependency:
```bash
uv run poe all-tests
uv run poe add-dependency-and-validate-bounds -P core -D "<dependency-spec>"
```
#### `all-tests-cov`
Same as `all-tests` but with coverage reporting enabled:
#### `upgrade-dev-dependencies`
Refresh exact dev dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests:
```bash
uv run poe all-tests-cov
uv run poe upgrade-dev-dependencies
```
Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.
### Building and Publishing
#### `build`
Build all packages:
```bash
uv run poe build
```
#### `clean-dist`
Clean the dist directories:
```bash
uv run poe clean-dist
```
#### `publish`
Publish packages to PyPI:
```bash
uv run poe publish
```
### Compatibility aliases
These legacy commands still work during the transition, but prefer the newer forms above:
```bash
uv run poe fmt # prefer: uv run poe syntax -F
uv run poe format # prefer: uv run poe syntax -F
uv run poe lint # prefer: uv run poe syntax -C
uv run poe all-tests # prefer: uv run poe test -A
uv run poe all-tests-cov # prefer: uv run poe test -A -C
uv run poe samples-lint # prefer: uv run poe syntax -S -C
uv run poe samples-syntax # prefer: uv run poe pyright -S
```
## Prek Hooks
Prek hooks run automatically on commit and execute a subset of the checks on changed files only. Package-level checks (fmt, lint, pyright) run in parallel but only for packages with changed files. Markdown and sample checks are skipped when no relevant files were changed. If the `core` package is changed, all packages are checked. You can also run all checks using prek directly:
Prek hooks run automatically on commit and stay intentionally lightweight:
- changed-package syntax formatting
- changed-package syntax lint/check
- markdown code lint only when markdown files change
- sample lint + sample pyright only when files under `samples/` change
They do **not** run workspace `pyright` or `mypy` by default. Use `uv run poe pyright`, `uv run poe mypy`, `uv run poe typing`, `uv run poe check-packages`, or `uv run poe check` when you want deeper validation.
You can run the installed hooks directly with:
```bash
uv run prek run -a
@@ -6,7 +6,7 @@ import base64
import json
import re
import uuid
from collections.abc import AsyncIterable, Awaitable, Sequence
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, Final, Literal, TypeAlias, overload
import httpx
@@ -35,10 +35,12 @@ from agent_framework import (
AgentResponseUpdate,
AgentSession,
BaseAgent,
BaseHistoryProvider,
Content,
ContinuationToken,
Message,
ResponseStream,
SessionContext,
normalize_messages,
prepend_agent_framework_to_user_agent,
)
@@ -226,6 +228,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
*,
stream: Literal[False] = ...,
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -238,17 +242,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
*,
stream: Literal[True],
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
def run( # pyright: ignore[reportIncompatibleMethodOverride]
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -261,28 +269,53 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
Keyword Args:
stream: Whether to stream the response. Defaults to False.
session: The conversation session associated with the message(s).
function_invocation_kwargs: Present for compatibility with the shared agent interface.
A2AAgent does not use these values directly.
client_kwargs: Present for compatibility with the shared agent interface.
A2AAgent does not use these values directly.
kwargs: Additional compatibility keyword arguments.
A2AAgent does not use these values directly.
continuation_token: Optional token to resume a long-running task
instead of starting a new one.
background: When True, in-progress task updates surface continuation
tokens so the caller can poll or resubscribe later. When False
(default), the agent internally waits for the task to complete.
kwargs: Additional keyword arguments.
Returns:
When stream=False: An Awaitable[AgentResponse].
When stream=True: A ResponseStream of AgentResponseUpdate items.
"""
del function_invocation_kwargs, client_kwargs, kwargs
normalized_messages = normalize_messages(messages)
if continuation_token is not None:
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
TaskIdParams(id=continuation_token["task_id"])
)
else:
normalized_messages = normalize_messages(messages)
if not normalized_messages:
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
a2a_stream = self.client.send_message(a2a_message)
provider_session = session
if provider_session is None and self.context_providers:
provider_session = AgentSession()
session_context = SessionContext(
session_id=provider_session.session_id if provider_session else None,
service_session_id=provider_session.service_session_id if provider_session else None,
input_messages=normalized_messages or [],
options={},
)
response = ResponseStream(
self._map_a2a_stream(a2a_stream, background=background),
self._map_a2a_stream(
a2a_stream,
background=background,
session=provider_session,
session_context=session_context,
),
finalizer=AgentResponse.from_updates,
)
if stream:
@@ -294,6 +327,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
a2a_stream: AsyncIterable[A2AStreamItem],
*,
background: bool = False,
session: AgentSession | None = None,
session_context: SessionContext | None = None,
) -> AsyncIterable[AgentResponseUpdate]:
"""Map raw A2A protocol items to AgentResponseUpdates.
@@ -304,24 +339,52 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
background: When False, in-progress task updates are silently
consumed (the stream keeps iterating until a terminal state).
When True, they are yielded with a continuation token.
session: The agent session for context providers.
session_context: The session context for context providers.
"""
if session_context is None:
session_context = SessionContext(input_messages=[], options={})
# Run before_run providers (forward order)
for provider in self.context_providers:
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
continue
if session is None:
raise RuntimeError("Provider session must be available when context providers are configured.")
await provider.before_run(
agent=self, # type: ignore[arg-type]
session=session,
context=session_context,
state=session.state.setdefault(provider.source_id, {}),
)
all_updates: list[AgentResponseUpdate] = []
async for item in a2a_stream:
if isinstance(item, A2AMessage):
# Process A2A Message
contents = self._parse_contents_from_a2a(item.parts)
yield AgentResponseUpdate(
update = AgentResponseUpdate(
contents=contents,
role="assistant" if item.role == A2ARole.agent else "user",
response_id=str(getattr(item, "message_id", uuid.uuid4())),
raw_representation=item,
)
all_updates.append(update)
yield update
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
task, _update_event = item
for update in self._updates_from_task(task, background=background):
all_updates.append(update)
yield update
else:
raise NotImplementedError("Only Message and Task responses are supported")
# Set the response on the context for after_run providers
if all_updates:
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
await self._run_after_providers(session=session, context=session_context)
# ------------------------------------------------------------------
# Task helpers
# ------------------------------------------------------------------
@@ -474,13 +537,14 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
raise ValueError(f"Unknown content type: {content.type}")
# Exclude framework-internal keys (e.g. attribution) from wire metadata
internal_keys = {"_attribution"}
internal_keys = {"_attribution", "context_id"}
metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None
return A2AMessage(
role=A2ARole("user"),
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
context_id=message.additional_properties.get("context_id"),
metadata=metadata,
)
+10 -6
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260311"
version = "1.0.0b260319"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"a2a-sdk>=0.3.5",
"agent-framework-core>=1.0.0rc5",
"a2a-sdk>=0.3.5,<0.3.24",
]
[tool.uv]
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
test = "pytest -m \"not integration\" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
+206 -1
View File
@@ -23,11 +23,14 @@ from a2a.types import Role as A2ARole
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentSession,
BaseContextProvider,
Content,
Message,
SessionContext,
)
from agent_framework.a2a import A2AAgent
from pytest import fixture, raises
from pytest import fixture, mark, raises
from agent_framework_a2a import A2AContinuationToken
from agent_framework_a2a._agent import _get_uri_data # type: ignore
@@ -507,6 +510,23 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
def test_prepare_message_for_a2a_forwards_context_id() -> None:
"""Test conversion of Message preserves context_id without duplicating it in metadata."""
agent = A2AAgent(client=MagicMock(), _http_client=None)
message = Message(
role="user",
contents=[Content.from_text(text="Continue the task")],
additional_properties={"context_id": "ctx-123", "trace_id": "trace-456"},
)
result = agent._prepare_message_for_a2a(message)
assert result.context_id == "ctx-123"
assert result.metadata == {"trace_id": "trace-456"}
def test_parse_contents_from_a2a_with_data_part() -> None:
"""Test conversion of A2A DataPart."""
@@ -834,3 +854,188 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A
# endregion
# region Context Provider Tests
class TrackingContextProvider(BaseContextProvider):
"""A context provider that records when before_run and after_run are called."""
def __init__(self) -> None:
super().__init__(source_id="tracking-provider")
self.before_run_called = False
self.after_run_called = False
self.before_run_context: SessionContext | None = None
self.after_run_context: SessionContext | None = None
async def before_run(
self,
*,
agent: Any,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
self.before_run_called = True
self.before_run_context = context
async def after_run(
self,
*,
agent: Any,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
self.after_run_called = True
self.after_run_context = context
async def test_run_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
"""Test that context providers are invoked during non-streaming run."""
provider = TrackingContextProvider()
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
context_providers=[provider],
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Hello from A2A")
session = agent.create_session()
response = await agent.run("Hello", session=session)
assert provider.before_run_called
assert provider.after_run_called
assert response.text == "Hello from A2A"
async def test_run_streaming_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
"""Test that context providers are invoked during streaming run."""
provider = TrackingContextProvider()
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
context_providers=[provider],
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Streamed response")
session = agent.create_session()
stream = agent.run("Hello", stream=True, session=session)
updates = []
async for update in stream:
updates.append(update)
assert provider.before_run_called
assert provider.after_run_called
assert len(updates) == 1
assert updates[0].text == "Streamed response"
async def test_context_providers_receive_response(mock_a2a_client: MockA2AClient) -> None:
"""Test that after_run providers can access the response via session context."""
provider = TrackingContextProvider()
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
context_providers=[provider],
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Response text")
session = agent.create_session()
await agent.run("Hello", session=session)
assert provider.after_run_context is not None
assert provider.after_run_context.response is not None
assert provider.after_run_context.response.text == "Response text"
async def test_context_providers_receive_input_messages(mock_a2a_client: MockA2AClient) -> None:
"""Test that before_run providers can access input messages via session context."""
provider = TrackingContextProvider()
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
context_providers=[provider],
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Reply")
session = agent.create_session()
await agent.run("Hello world", session=session)
assert provider.before_run_context is not None
assert len(provider.before_run_context.input_messages) > 0
assert provider.before_run_context.input_messages[-1].text == "Hello world"
async def test_run_without_context_providers(mock_a2a_client: MockA2AClient) -> None:
"""Test that run works normally when no context providers are configured."""
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Hello")
response = await agent.run("Hello")
assert response.text == "Hello"
async def test_run_creates_session_for_providers_when_none_provided(mock_a2a_client: MockA2AClient) -> None:
"""Test that a session is auto-created when context providers are configured but no session is passed."""
provider = TrackingContextProvider()
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
context_providers=[provider],
http_client=None,
)
mock_a2a_client.add_message_response("msg-1", "Hello")
await agent.run("Hello")
assert provider.before_run_called
assert provider.after_run_called
@mark.parametrize("messages", [None, []])
async def test_run_raises_when_no_messages_and_no_continuation_token(
mock_a2a_client: MockA2AClient, messages: list[str] | None
) -> None:
"""Test that run() raises ValueError when messages is None/empty and no continuation_token is provided."""
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
http_client=None,
)
with raises(ValueError, match="At least one message is required"):
await agent.run(messages)
async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_client: MockA2AClient) -> None:
"""Test that run() does not raise when messages is None but a continuation_token is provided."""
task = Task(
id="task-cont",
context_id="ctx-cont",
status=TaskStatus(state=TaskState.completed, message=None),
)
mock_a2a_client.resubscribe_responses.append((task, None))
agent = A2AAgent(
name="Test Agent",
client=mock_a2a_client,
http_client=None,
)
token = A2AContinuationToken(task_id="task-cont", context_id="ctx-cont")
response = await agent.run(None, continuation_token=token)
assert response is not None
# endregion
@@ -21,6 +21,7 @@ from ag_ui.core import (
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import (
@@ -369,6 +370,24 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]:
return events
def _make_approval_tool_result_events(resolved_approval_results: list[Content]) -> list[ToolCallResultEvent]:
"""Build TOOL_CALL_RESULT events for tools executed during approval resolution."""
events: list[ToolCallResultEvent] = []
for resolved in resolved_approval_results:
if resolved.call_id:
raw = resolved.result if resolved.result is not None else ""
result_str = raw if isinstance(raw, str) else json.dumps(make_json_safe(raw))
events.append(
ToolCallResultEvent(
message_id=generate_event_id(),
tool_call_id=resolved.call_id,
content=result_str,
role="tool",
)
)
return events
def _evict_oldest_approvals(registry: dict[str, str], max_size: int = 10_000) -> None:
"""Evict the oldest entries from the pending-approvals registry (LRU).
@@ -391,7 +410,7 @@ async def _resolve_approval_responses(
run_kwargs: dict[str, Any],
pending_approvals: dict[str, str] | None = None,
thread_id: str = "",
) -> None:
) -> list[Content]:
"""Execute approved function calls and replace approval content with results.
This modifies the messages list in place, replacing function_approval_response
@@ -407,10 +426,16 @@ async def _resolve_approval_responses(
When provided, every approval response is validated against this
registry to prevent bypass, function name spoofing, and replay.
thread_id: The conversation thread ID used to scope registry keys.
Returns:
List of approved function_result Content objects only (empty if no
approvals). Rejection results are written into the message history
but are *not* included in the return value because they should not
be emitted as TOOL_CALL_RESULT events.
"""
fcc_todo = _collect_approval_responses(messages)
if not fcc_todo:
return
return []
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
rejected_responses = [resp for resp in fcc_todo.values() if not resp.approved]
@@ -493,31 +518,23 @@ async def _resolve_approval_responses(
logger.exception("Failed to execute approved tool calls; injecting error results: %s", e)
approved_function_results = []
# Build normalized results for approved responses
normalized_results: list[Content] = []
# Build results for approved responses (used for TOOL_CALL_RESULT event emission)
approved_results: list[Content] = []
for idx, approval in enumerate(approved_responses):
if (
idx < len(approved_function_results)
and getattr(approved_function_results[idx], "type", None) == "function_result"
):
normalized_results.append(approved_function_results[idx])
approved_results.append(approved_function_results[idx])
continue
# Get call_id from function_call if present, otherwise use approval.id
func_call = approval.function_call
call_id = (func_call.call_id if func_call else None) or approval.id or ""
normalized_results.append(
approved_results.append(
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")
)
# Build rejection results
for rejection in rejected_responses:
func_call = rejection.function_call
call_id = (func_call.call_id if func_call else None) or rejection.id or ""
normalized_results.append(
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation was rejected by user.")
)
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
_replace_approval_contents_with_results(messages, fcc_todo, approved_results) # type: ignore
# Post-process: Convert user messages with function_result content to proper tool messages.
# After _replace_approval_contents_with_results, approved tool calls have their results
@@ -525,6 +542,8 @@ async def _resolve_approval_responses(
# This transformation ensures the message history is valid for the LLM provider.
_convert_approval_results_to_tool_messages(messages)
return approved_results
def _convert_approval_results_to_tool_messages(messages: list[Message]) -> None:
"""Convert function_result content in user messages to proper tool messages.
@@ -787,7 +806,9 @@ async def run_agent_stream(
# Resolve approval responses (execute approved tools, replace approvals with results)
# This must happen before running the agent so it sees the tool results
tools_for_execution = tools if tools is not None else server_tools
await _resolve_approval_responses(messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id)
resolved_approval_results = await _resolve_approval_responses(
messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id
)
# Defense-in-depth: replace approval payloads in snapshot with actual tool results
# so CopilotKit does not re-send stale approval content on subsequent turns.
@@ -851,6 +872,9 @@ async def run_agent_stream(
yield StateSnapshotEvent(snapshot=flow.current_state)
run_started_emitted = True
for event in _make_approval_tool_result_events(resolved_approval_results):
yield event
# Feature #4: Detect tool-only messages (no text content)
# Emit TextMessageStartEvent to create message context for tool calls
if not flow.message_id and _has_only_tool_calls(update.contents):
@@ -905,7 +929,8 @@ async def run_agent_stream(
if state_schema and flow.current_state:
yield StateSnapshotEvent(snapshot=flow.current_state)
# Process structured output if response_format is set
for event in _make_approval_tool_result_events(resolved_approval_results):
yield event
if response_format is not None and all_updates:
from agent_framework import AgentResponse
from pydantic import BaseModel
@@ -1015,7 +1040,7 @@ async def run_agent_stream(
flow.tool_calls_by_id[confirm_id] = confirm_entry
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
flow.waiting_for_approval = True
flow.interrupts = [
flow.interrupts.append(
{
"id": str(confirm_id),
"value": {
@@ -1027,7 +1052,7 @@ async def run_agent_stream(
},
},
}
]
)
# Close any open message
if flow.message_id:
@@ -111,8 +111,8 @@ def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClien
@_apply_server_function_call_unwrap
class AGUIChatClient(
ChatMiddlewareLayer[AGUIChatOptionsT],
FunctionInvocationLayer[AGUIChatOptionsT],
ChatMiddlewareLayer[AGUIChatOptionsT],
ChatTelemetryLayer[AGUIChatOptionsT],
BaseChatClient[AGUIChatOptionsT],
Generic[AGUIChatOptionsT],
@@ -220,7 +220,6 @@ class AGUIChatClient(
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
) -> None:
"""Initialize the AG-UI chat client.
@@ -231,13 +230,11 @@ class AGUIChatClient(
additional_properties: Additional properties to store
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
**kwargs: Additional arguments passed to BaseChatClient
"""
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
self._http_service = AGUIHttpService(
endpoint=endpoint,
@@ -242,8 +242,16 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
unique_messages.append(msg)
else:
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
key = (role_value, hash(content_str))
# Use message_id for deduplication when available — two messages with the
# same id are definitively the same message (e.g. upstream replays), while
# different messages that happen to share identical content (e.g. repeated
# "yes" confirmations) will have distinct ids and be preserved.
# Fall back to content-hash when message_id is absent or empty.
if msg.message_id:
key = ("id", msg.message_id)
else:
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
key = ("content", role_value, hash(content_str))
if key in seen_keys:
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
@@ -8,6 +8,7 @@ import logging
from typing import TYPE_CHECKING, Any
from agent_framework import BaseChatClient
from agent_framework._tools import _append_unique_tools # pyright: ignore[reportPrivateUsage]
if TYPE_CHECKING:
from agent_framework import SupportsAgentRun
@@ -22,7 +23,7 @@ def _collect_mcp_tool_functions(mcp_tools: list[Any]) -> list[Any]:
mcp_tools: List of MCP tool instances.
Returns:
List of functions from connected MCP tools.
Functions from connected MCP tools.
"""
functions: list[Any] = []
for mcp_tool in mcp_tools:
@@ -56,7 +57,11 @@ def collect_server_tools(agent: SupportsAgentRun) -> list[Any]:
# Include functions from connected MCP tools (only available on Agent)
mcp_tools = getattr(agent, "mcp_tools", None)
if mcp_tools:
server_tools.extend(_collect_mcp_tool_functions(mcp_tools))
_append_unique_tools(
server_tools,
_collect_mcp_tool_functions(mcp_tools),
duplicate_error_message="Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool.",
)
logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools")
for tool in server_tools:
@@ -109,26 +114,13 @@ def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list
logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)")
return None
server_tool_names = {getattr(tool, "name", None) for tool in server_tools}
unique_client_tools = [tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names]
if not unique_client_tools:
# Same check: must pass server tools if any require approval
if server_tools and _has_approval_tools(server_tools):
logger.info(
f"[TOOLS] Client tools duplicate server but server has approval tools - "
f"passing {len(server_tools)} server tools for approval mode"
)
return server_tools
logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter")
return None
combined_tools: list[Any] = []
if server_tools:
combined_tools.extend(server_tools)
combined_tools.extend(unique_client_tools)
combined_tools = _append_unique_tools(
list(server_tools),
client_tools,
duplicate_error_message="Tool names must be unique.",
)
logger.info(
f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools "
f"({len(server_tools)} server + {len(unique_client_tools)} unique client)"
f"({len(server_tools)} server + {len(client_tools)} client)"
)
return combined_tools
@@ -12,6 +12,12 @@ from typing import Any, cast
from ag_ui.core import (
BaseEvent,
CustomEvent,
ReasoningEncryptedValueEvent,
ReasoningEndEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
RunFinishedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
@@ -224,27 +230,28 @@ def _emit_tool_call(
return events
def _emit_tool_result(
content: Content,
def _emit_tool_result_common(
call_id: str,
raw_result: Any,
flow: FlowState,
predictive_handler: PredictiveStateHandler | None = None,
) -> list[BaseEvent]:
"""Emit ToolCallResult events for function_result content."""
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result``
(MCP server tool results) delegate to this function.
"""
events: list[BaseEvent] = []
if not content.call_id:
return events
events.append(ToolCallEndEvent(tool_call_id=call_id))
flow.tool_calls_ended.add(call_id)
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
flow.tool_calls_ended.add(content.call_id)
raw_result = content.result if content.result is not None else ""
result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result))
message_id = generate_event_id()
events.append(
ToolCallResultEvent(
message_id=message_id,
tool_call_id=content.call_id,
tool_call_id=call_id,
content=result_content,
role="tool",
)
@@ -254,7 +261,7 @@ def _emit_tool_result(
{
"id": message_id,
"role": "tool",
"toolCallId": content.call_id,
"toolCallId": call_id,
"content": result_content,
}
)
@@ -268,7 +275,7 @@ def _emit_tool_result(
flow.tool_call_name = None
if flow.message_id:
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
logger.debug("Closing text message: message_id=%s", flow.message_id)
events.append(TextMessageEndEvent(message_id=flow.message_id))
flow.message_id = None
flow.accumulated_text = ""
@@ -276,6 +283,18 @@ def _emit_tool_result(
return events
def _emit_tool_result(
content: Content,
flow: FlowState,
predictive_handler: PredictiveStateHandler | None = None,
) -> list[BaseEvent]:
"""Emit ToolCallResult events for function_result content."""
if not content.call_id:
return []
raw_result = content.result if content.result is not None else ""
return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler)
def _emit_approval_request(
content: Content,
flow: FlowState,
@@ -320,7 +339,7 @@ def _emit_approval_request(
)
interrupt_id = func_call_id or content.id
if interrupt_id:
flow.interrupts = [
flow.interrupts.append(
{
"id": str(interrupt_id),
"value": {
@@ -332,7 +351,7 @@ def _emit_approval_request(
},
},
}
]
)
if require_confirmation:
confirm_id = generate_event_id()
@@ -381,6 +400,107 @@ def _emit_oauth_consent(content: Content) -> list[BaseEvent]:
)
def _emit_mcp_tool_call(content: Content, flow: FlowState) -> list[BaseEvent]:
"""Emit ToolCall start/args events for MCP server tool call content.
MCP tool calls arrive as complete items (not streamed deltas), so we emit a
``ToolCallStartEvent`` (and, when arguments are present, a ``ToolCallArgsEvent``)
immediately. This maps MCP-specific fields (tool_name, server_name) to the
same AG-UI ToolCall* events used by regular function calls, making MCP tool
execution visible to AG-UI consumers. Completion/end events are handled
separately by ``_emit_mcp_tool_result``.
"""
events: list[BaseEvent] = []
tool_call_id = content.call_id or generate_event_id()
tool_name = content.tool_name or "mcp_tool"
display_name = tool_name
events.append(
ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=display_name,
parent_message_id=flow.message_id,
)
)
# Serialize arguments
args_str = ""
if content.arguments:
args_str = (
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
)
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_str))
# Track in flow state for MESSAGES_SNAPSHOT
tool_entry = {
"id": tool_call_id,
"type": "function",
"function": {"name": display_name, "arguments": args_str},
}
flow.pending_tool_calls.append(tool_entry)
flow.tool_calls_by_id[tool_call_id] = tool_entry
return events
def _emit_mcp_tool_result(
content: Content, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None
) -> list[BaseEvent]:
"""Emit ToolCallResult events for MCP server tool result content.
Delegates to the shared _emit_tool_result_common helper using content.output
(the MCP-specific result field) instead of content.result.
"""
if not content.call_id:
logger.warning("MCP tool result content missing call_id, skipping")
return []
raw_output = content.output if content.output is not None else ""
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
"""Emit AG-UI reasoning events for text_reasoning content.
Uses the protocol-defined reasoning event types so that AG-UI consumers
such as CopilotKit can render reasoning natively.
Only ``content.text`` is used for the visible reasoning message. If
``content.protected_data`` is present it is emitted as a
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
reasoning for state continuity without conflating it with display text.
"""
text = content.text or ""
if not text and content.protected_data is None:
return []
message_id = content.id or generate_event_id()
events: list[BaseEvent] = [
ReasoningStartEvent(message_id=message_id),
ReasoningMessageStartEvent(message_id=message_id, role="assistant"),
]
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
events.append(ReasoningMessageEndEvent(message_id=message_id))
if content.protected_data is not None:
events.append(
ReasoningEncryptedValueEvent(
subtype="message",
entity_id=message_id,
encrypted_value=content.protected_data,
)
)
events.append(ReasoningEndEvent(message_id=message_id))
return events
def _emit_content(
content: Any,
flow: FlowState,
@@ -402,5 +522,11 @@ def _emit_content(
return _emit_usage(content)
if content_type == "oauth_consent_request":
return _emit_oauth_consent(content)
if content_type == "mcp_server_tool_call":
return _emit_mcp_tool_call(content, flow)
if content_type == "mcp_server_tool_result":
return _emit_mcp_tool_result(content, flow, predictive_handler)
if content_type == "text_reasoning":
return _emit_text_reasoning(content)
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
return []
@@ -6,13 +6,12 @@ from __future__ import annotations
import logging
import os
from typing import cast
from typing import Any, cast
import uvicorn
from agent_framework import ChatOptions
from agent_framework._clients import SupportsChatGetResponse
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.anthropic import AnthropicClient
from agent_framework.azure import AzureOpenAIChatClient
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -26,6 +25,15 @@ from ..agents.task_steps_agent import task_steps_agent_wrapped
from ..agents.ui_generator_agent import ui_generator_agent
from ..agents.weather_agent import weather_agent
AnthropicClient: type[Any] | None
try:
import agent_framework.anthropic as _anthropic_namespace
except ImportError:
# If the Anthropic client isn't installed, we can still run the server with Azure OpenAI as the default chat client
AnthropicClient = None
else:
AnthropicClient = cast(type[Any] | None, getattr(_anthropic_namespace, "AnthropicClient", None))
# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable)
if os.getenv("ENABLE_DEBUG_LOGGING"):
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log")
@@ -70,7 +78,9 @@ app.add_middleware(
# Set CHAT_CLIENT=anthropic to use Anthropic, defaults to Azure OpenAI
client: SupportsChatGetResponse[ChatOptions] = cast(
SupportsChatGetResponse[ChatOptions],
AnthropicClient() if os.getenv("CHAT_CLIENT", "").lower() == "anthropic" else AzureOpenAIChatClient(),
AnthropicClient()
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
else AzureOpenAIChatClient(),
)
# Agentic Chat - basic chat agent
+14 -10
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260311"
version = "1.0.0b260319"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,16 +22,16 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"ag-ui-protocol>=0.1.9",
"fastapi>=0.115.0",
"uvicorn>=0.30.0"
"agent-framework-core>=1.0.0rc5",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
"pytest==9.0.2",
"httpx==0.28.1",
]
[build-system]
@@ -72,6 +72,10 @@ typeCheckingMode = "basic"
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
test = "pytest -m \"not integration\" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui'
@@ -45,8 +45,8 @@ def pytest_configure() -> None:
class StreamingChatClientStub(
ChatMiddlewareLayer[OptionsCoT],
FunctionInvocationLayer[OptionsCoT],
ChatMiddlewareLayer[OptionsCoT],
ChatTelemetryLayer[OptionsCoT],
BaseChatClient[OptionsCoT],
Generic[OptionsCoT],
@@ -54,7 +54,7 @@ class StreamingChatClientStub(
"""Typed streaming stub that satisfies SupportsChatGetResponse."""
def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None:
super().__init__(function_middleware=[])
super().__init__(middleware=[])
self._stream_fn = stream_fn
self._response_fn = response_fn
self.last_session: AgentSession | None = None
@@ -98,7 +98,11 @@ class StreamingChatClientStub(
options: OptionsCoT | ChatOptions[Any] | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
self.last_session = kwargs.get("session")
client_kwargs = kwargs.get("client_kwargs")
if isinstance(client_kwargs, Mapping):
self.last_session = cast(AgentSession | None, client_kwargs.get("session"))
else:
self.last_session = None
self.last_service_session_id = self.last_session.service_session_id if self.last_session else None
return cast(
Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
@@ -702,14 +702,9 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub
"""Test that when use_service_session is True, the AgentSession used to run the agent is set to the service session ID."""
from agent_framework.ag_ui import AgentFrameworkAgent
request_service_session_id: str | None = None
async def stream_fn(
messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
nonlocal request_service_session_id
session = kwargs.get("session")
request_service_session_id = session.service_session_id if session else None
yield ChatResponseUpdate(
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
@@ -719,11 +714,22 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
# Spy on agent.run to capture the session kwarg at call time (before streaming mutates it)
captured_service_session_id: str | None = None
original_run = agent.run
def capturing_run(*args: Any, **kwargs: Any) -> Any:
nonlocal captured_service_session_id
session = kwargs.get("session")
captured_service_session_id = session.service_session_id if session else None
return original_run(*args, **kwargs)
agent.run = capturing_run # type: ignore[assignment, method-assign]
events: list[Any] = []
async for event in wrapper.run(input_data):
events.append(event)
request_service_session_id = agent.client.last_service_session_id
assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set)
assert captured_service_session_id == "conv_123456"
async def test_function_approval_mode_executes_tool(streaming_chat_client_stub):
@@ -0,0 +1,450 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for TOOL_CALL_RESULT event emission on approval resume flows."""
from __future__ import annotations
import json
from typing import Any
from agent_framework import AgentResponseUpdate, Content, FunctionTool
from conftest import StubAgent
from agent_framework_ag_ui._agent import AgentConfig
from agent_framework_ag_ui._agent_run import run_agent_stream
def _make_weather_tool() -> FunctionTool:
"""Create a real executable weather tool with approval_mode='always_require'."""
def get_weather(city: str) -> str:
return f"Sunny in {city}"
return FunctionTool(
name="get_weather",
description="Get the weather for a city",
func=get_weather,
approval_mode="always_require",
)
async def test_approval_resume_emits_tool_call_result() -> None:
"""After approving a tool call, the resume stream should contain a TOOL_CALL_RESULT event.
The message format follows the AG-UI approval pattern:
- assistant message with tool_calls
- tool message with {"accepted": true} content and toolCallId
"""
tool_name = "get_weather"
call_id = "call_abc123"
weather_tool = _make_weather_tool()
agent = StubAgent(
updates=[AgentResponseUpdate(contents=[Content.from_text(text="The weather is sunny.")], role="assistant")],
default_options={"tools": [weather_tool]},
)
config = AgentConfig()
# Build resume messages: user query, assistant tool call, approval response
resume_messages: list[dict[str, Any]] = [
{"role": "user", "content": "What's the weather in Seattle?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps({"city": "Seattle"}),
},
}
],
},
{
"role": "tool",
"content": json.dumps({"accepted": True}),
"toolCallId": call_id,
},
]
input_data: dict[str, Any] = {
"thread_id": "thread-approval-result",
"run_id": "run-resume",
"messages": resume_messages,
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
event_types = [getattr(e, "type", None) for e in events]
assert "RUN_STARTED" in event_types, f"Expected RUN_STARTED, got types: {event_types}"
assert "RUN_FINISHED" in event_types, f"Expected RUN_FINISHED, got types: {event_types}"
# TOOL_CALL_RESULT must be present for the approved tool
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
assert len(tool_result_events) > 0, (
f"Expected at least one TOOL_CALL_RESULT event for the approved tool, "
f"but found none. Event types in stream: {event_types}"
)
result_event = tool_result_events[0]
assert result_event.tool_call_id == call_id, (
f"Expected TOOL_CALL_RESULT with tool_call_id={call_id}, got tool_call_id={result_event.tool_call_id}"
)
# Verify the result contains the actual tool execution output
assert result_event.content == "Sunny in Seattle"
async def test_approval_resume_result_has_content() -> None:
"""TOOL_CALL_RESULT event from an approved tool should contain the execution result."""
tool_name = "get_weather"
call_id = "call_content_check"
weather_tool = _make_weather_tool()
agent = StubAgent(
updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")],
default_options={"tools": [weather_tool]},
)
config = AgentConfig()
resume_messages: list[dict[str, Any]] = [
{"role": "user", "content": "Check the weather"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps({"city": "Portland"}),
},
}
],
},
{
"role": "tool",
"content": json.dumps({"accepted": True}),
"toolCallId": call_id,
},
]
input_data: dict[str, Any] = {
"thread_id": "thread-result-content",
"run_id": "run-resume-2",
"messages": resume_messages,
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
assert len(tool_result_events) == 1
result_event = tool_result_events[0]
assert result_event.tool_call_id == call_id
assert result_event.role == "tool"
# Verify the result contains the actual tool execution output (string returned directly)
assert result_event.content == "Sunny in Portland"
async def test_no_approval_no_extra_tool_result() -> None:
"""When no approval response is present, no extra TOOL_CALL_RESULT events should be emitted."""
agent = StubAgent(updates=[AgentResponseUpdate(contents=[Content.from_text(text="Hello.")], role="assistant")])
config = AgentConfig()
input_data: dict[str, Any] = {
"thread_id": "thread-no-approval",
"run_id": "run-normal",
"messages": [{"role": "user", "content": "Hi"}],
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
assert len(tool_result_events) == 0, f"Unexpected TOOL_CALL_RESULT events: {tool_result_events}"
async def test_rejection_does_not_emit_tool_call_result() -> None:
"""Rejected tool calls should not produce TOOL_CALL_RESULT events."""
tool_name = "get_weather"
call_id = "call_rejected"
weather_tool = _make_weather_tool()
agent = StubAgent(
updates=[AgentResponseUpdate(contents=[Content.from_text(text="OK, I won't check.")], role="assistant")],
default_options={"tools": [weather_tool]},
)
config = AgentConfig()
resume_messages: list[dict[str, Any]] = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps({"city": "Denver"}),
},
}
],
},
{
"role": "tool",
"content": json.dumps({"accepted": False}),
"toolCallId": call_id,
},
]
input_data: dict[str, Any] = {
"thread_id": "thread-rejection",
"run_id": "run-rejected",
"messages": resume_messages,
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
assert len(tool_result_events) == 0, (
f"Expected no TOOL_CALL_RESULT for rejected tool, got {len(tool_result_events)}"
)
def _make_temperature_tool() -> FunctionTool:
"""Create a real executable temperature tool with approval_mode='always_require'."""
def get_temperature(city: str) -> str:
return f"72F in {city}"
return FunctionTool(
name="get_temperature",
description="Get the temperature for a city",
func=get_temperature,
approval_mode="always_require",
)
async def test_mixed_approve_reject_emits_only_approved_tool_result() -> None:
"""When one tool call is approved and another rejected, only the approved one produces a TOOL_CALL_RESULT event."""
weather_tool = _make_weather_tool()
temperature_tool = _make_temperature_tool()
approved_call_id = "call_approved"
rejected_call_id = "call_rejected"
agent = StubAgent(
updates=[AgentResponseUpdate(contents=[Content.from_text(text="Here are the results.")], role="assistant")],
default_options={"tools": [weather_tool, temperature_tool]},
)
config = AgentConfig()
resume_messages: list[dict[str, Any]] = [
{"role": "user", "content": "Weather and temperature in Seattle?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": approved_call_id,
"type": "function",
"function": {
"name": "get_weather",
"arguments": json.dumps({"city": "Seattle"}),
},
},
{
"id": rejected_call_id,
"type": "function",
"function": {
"name": "get_temperature",
"arguments": json.dumps({"city": "Seattle"}),
},
},
],
},
{
"role": "tool",
"content": json.dumps({"accepted": True}),
"toolCallId": approved_call_id,
},
{
"role": "tool",
"content": json.dumps({"accepted": False}),
"toolCallId": rejected_call_id,
},
]
input_data: dict[str, Any] = {
"thread_id": "thread-mixed",
"run_id": "run-mixed",
"messages": resume_messages,
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
# Only the approved tool call should produce a TOOL_CALL_RESULT event
assert len(tool_result_events) == 1, (
f"Expected exactly 1 TOOL_CALL_RESULT (approved only), got {len(tool_result_events)}"
)
assert tool_result_events[0].tool_call_id == approved_call_id
assert tool_result_events[0].content == "Sunny in Seattle"
async def test_approval_resume_zero_updates_emits_tool_result() -> None:
"""When the agent produces zero updates, TOOL_CALL_RESULT events should still be emitted via the fallback path."""
tool_name = "get_weather"
call_id = "call_zero_updates"
weather_tool = _make_weather_tool()
agent = StubAgent(
updates=[],
default_options={"tools": [weather_tool]},
)
config = AgentConfig()
resume_messages: list[dict[str, Any]] = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps({"city": "Boston"}),
},
}
],
},
{
"role": "tool",
"content": json.dumps({"accepted": True}),
"toolCallId": call_id,
},
]
input_data: dict[str, Any] = {
"thread_id": "thread-zero-updates",
"run_id": "run-zero-updates",
"messages": resume_messages,
}
events: list[Any] = []
async for event in run_agent_stream(input_data, agent, config):
events.append(event)
event_types = [getattr(e, "type", None) for e in events]
assert "RUN_STARTED" in event_types
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
assert len(tool_result_events) == 1, (
f"Expected 1 TOOL_CALL_RESULT in zero-updates fallback path, got {len(tool_result_events)}"
)
assert tool_result_events[0].tool_call_id == call_id
assert tool_result_events[0].content == "Sunny in Boston"
async def test_resolve_approval_responses_returns_only_approved() -> None:
"""_resolve_approval_responses should return only approved results; rejection results go into messages only."""
from agent_framework import Message
from agent_framework_ag_ui._agent_run import _resolve_approval_responses
weather_tool = _make_weather_tool()
temperature_tool = _make_temperature_tool()
approved_call_id = "call_a"
rejected_call_id = "call_r"
messages: list[Any] = [
Message(role="user", contents=[Content.from_text(text="Hi")]),
Message(
role="assistant",
contents=[
Content(
type="function_approval_request",
id=approved_call_id,
function_call=Content(
type="function_call",
name="get_weather",
call_id=approved_call_id,
arguments='{"city": "NYC"}',
),
),
Content(
type="function_approval_request",
id=rejected_call_id,
function_call=Content(
type="function_call",
name="get_temperature",
call_id=rejected_call_id,
arguments='{"city": "NYC"}',
),
),
],
),
Message(
role="user",
contents=[
Content(
type="function_approval_response",
id=approved_call_id,
approved=True,
function_call=Content(
type="function_call",
name="get_weather",
call_id=approved_call_id,
arguments='{"city": "NYC"}',
),
),
Content(
type="function_approval_response",
id=rejected_call_id,
approved=False,
function_call=Content(
type="function_call",
name="get_temperature",
call_id=rejected_call_id,
arguments='{"city": "NYC"}',
),
),
],
),
]
agent = StubAgent(
updates=[],
default_options={"tools": [weather_tool, temperature_tool]},
)
results = await _resolve_approval_responses(messages, [weather_tool, temperature_tool], agent, {})
# Return value should only contain approved results
assert len(results) == 1
assert results[0].call_id == approved_call_id
assert results[0].type == "function_result"
# Rejection result should be written into messages (by _replace_approval_contents_with_results)
all_contents = [c for msg in messages for c in msg.contents]
rejection_results = [c for c in all_contents if c.type == "function_result" and c.call_id == rejected_call_id]
assert len(rejection_results) == 1
assert "rejected" in str(rejection_results[0].result).lower()
@@ -213,3 +213,134 @@ def test_sse_response_headers() -> None:
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
assert response.headers.get("cache-control") == "no-cache"
# ── MCP tool call SSE round-trip ──
def test_mcp_tool_call_sse_round_trip() -> None:
"""MCP tool call + result events survive SSE encoding/parsing round-trip."""
app = _build_app_with_agent(
[
AgentResponseUpdate(
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp-1",
tool_name="search",
server_name="brave",
arguments={"query": "weather"},
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp-1",
output={"results": ["sunny"]},
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's sunny!")],
role="assistant",
),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
stream.assert_no_run_error()
# Verify MCP tool call details survive SSE encoding
start = stream.first("TOOL_CALL_START")
assert start.tool_call_name == "search"
assert start.tool_call_id == "mcp-1"
# Verify the result came through
result = stream.first("TOOL_CALL_RESULT")
assert "sunny" in result.content
# ── Text reasoning SSE round-trip ──
def test_text_reasoning_sse_round_trip() -> None:
"""Text reasoning events survive SSE encoding/parsing round-trip."""
app = _build_app_with_agent(
[
AgentResponseUpdate(
contents=[
Content.from_text_reasoning(
id="reason-1",
text="The user wants weather info, I should use a tool.",
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="Let me check the weather.")],
role="assistant",
),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_text_messages_balanced()
stream.assert_no_run_error()
stream.assert_has_type("REASONING_START")
stream.assert_has_type("REASONING_MESSAGE_CONTENT")
stream.assert_has_type("REASONING_END")
# Verify reasoning content survives SSE encoding
raw_events = parse_sse_response(response.content)
reasoning_content = [e for e in raw_events if e["type"] == "REASONING_MESSAGE_CONTENT"]
assert len(reasoning_content) == 1
assert "weather" in reasoning_content[0]["delta"]
def test_text_reasoning_with_encrypted_value_sse_round_trip() -> None:
"""Reasoning with protected_data emits ReasoningEncryptedValue through SSE."""
app = _build_app_with_agent(
[
AgentResponseUpdate(
contents=[
Content.from_text_reasoning(
id="reason-enc",
text="visible reasoning",
protected_data="encrypted-payload-abc123",
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="Done.")],
role="assistant",
),
]
)
client = TestClient(app)
response = client.post("/", json=USER_PAYLOAD)
assert response.status_code == 200
stream = parse_sse_to_event_stream(response.content)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_has_type("REASONING_ENCRYPTED_VALUE")
raw_events = parse_sse_response(response.content)
encrypted = [e for e in raw_events if e["type"] == "REASONING_ENCRYPTED_VALUE"]
assert len(encrypted) == 1
assert encrypted[0]["encryptedValue"] == "encrypted-payload-abc123"
assert encrypted[0]["entityId"] == "reason-enc"
assert encrypted[0]["subtype"] == "message"
@@ -1015,15 +1015,111 @@ def test_deduplicate_assistant_tool_calls():
assert len(result) == 1
def test_deduplicate_general_messages():
"""Duplicate general user messages are deduplicated."""
def test_deduplicate_by_message_id():
"""Messages with the same message_id are deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg1.message_id = "msg-1"
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg2.message_id = "msg-1"
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
assert result == [msg1]
def test_deduplicate_preserves_repeated_confirmations_with_distinct_ids():
"""Identical content with different message_ids is preserved."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
assistant = Message(role="assistant", contents=[Content.from_text(text="Are you sure?")])
assistant.message_id = "msg-1"
confirm1 = Message(role="user", contents=[Content.from_text(text="yes")])
confirm1.message_id = "msg-2"
confirm2 = Message(role="user", contents=[Content.from_text(text="yes")])
confirm2.message_id = "msg-3"
result = _deduplicate_messages([confirm1, assistant, confirm2])
assert result == [confirm1, assistant, confirm2]
def test_deduplicate_preserves_repeated_system_messages_with_distinct_ids():
"""Non-consecutive identical system messages with different ids are preserved."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
sys1 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
sys1.message_id = "msg-1"
user_msg = Message(role="user", contents=[Content.from_text(text="Hi")])
user_msg.message_id = "msg-2"
sys2 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
sys2.message_id = "msg-3"
result = _deduplicate_messages([sys1, user_msg, sys2])
assert result == [sys1, user_msg, sys2]
def test_deduplicate_skips_replayed_system_messages_with_same_id():
"""System messages replayed with the same message_id are deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msgs = []
for _ in range(3):
m = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
m.message_id = "msg-1"
msgs.append(m)
result = _deduplicate_messages(msgs)
assert len(result) == 1
def test_deduplicate_without_message_id_uses_content_hash():
"""Messages without message_id are deduplicated by content hash."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
result = _deduplicate_messages([msg1, msg2])
assert len(result) == 1
assert result == [msg1]
def test_deduplicate_without_message_id_preserves_different_content():
"""Messages without message_id but different content are preserved."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
result = _deduplicate_messages([msg1, msg2])
assert result == [msg1, msg2]
def test_deduplicate_handles_none_contents():
"""Messages with contents=None pass through without errors; duplicates are deduped."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=None)
msg2 = Message(role="assistant", contents=[Content.from_text(text="Hello")])
msg3 = Message(role="user", contents=None)
result = _deduplicate_messages([msg1, msg2, msg3])
assert result == [msg1, msg2]
def test_deduplicate_mixed_id_and_no_id():
"""Messages with and without message_id coexist correctly."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg1.message_id = "msg-1"
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) # no id
msg3 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg3.message_id = "msg-1" # duplicate of msg1
result = _deduplicate_messages([msg1, msg2, msg3])
assert len(result) == 2
assert result == [msg1, msg2]
def test_deduplicate_replaces_empty_tool_result():
@@ -1038,7 +1134,30 @@ def test_deduplicate_replaces_empty_tool_result():
assert result[0].contents[0].result == "actual result"
# ── Multimodal & content conversion edge cases ──
def test_deduplicate_empty_string_message_id_falls_back_to_content_hash():
"""Empty-string message_id is treated as missing; content-hash dedup is used."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg1.message_id = ""
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
msg2.message_id = ""
result = _deduplicate_messages([msg1, msg2])
assert result == [msg1, msg2], "Different content with empty IDs should both be preserved"
def test_deduplicate_empty_string_message_id_deduplicates_same_content():
"""Empty-string message_id with identical content should be deduplicated."""
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg1.message_id = ""
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
msg2.message_id = ""
result = _deduplicate_messages([msg1, msg2])
assert result == [msg1], "Same content with empty IDs should be deduplicated"
def test_convert_agui_content_unknown_source_type_fallback():
@@ -5,6 +5,12 @@
import pytest
from ag_ui.core import (
CustomEvent,
ReasoningEncryptedValueEvent,
ReasoningEndEvent,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
ReasoningStartEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
@@ -25,7 +31,10 @@ from agent_framework_ag_ui._run_common import (
_build_run_finished_event,
_emit_approval_request,
_emit_content,
_emit_mcp_tool_call,
_emit_mcp_tool_result,
_emit_text,
_emit_text_reasoning,
_emit_tool_call,
_emit_tool_result,
_extract_resume_payload,
@@ -538,6 +547,27 @@ def test_emit_approval_request_populates_interrupt_metadata():
assert flow.interrupts[0]["value"]["type"] == "function_approval_request"
def test_emit_approval_request_accumulates_multiple_interrupts():
"""Multiple approval requests in the same turn should accumulate in flow.interrupts."""
flow = FlowState(message_id="msg-1")
for i in range(1, 4):
function_call = Content.from_function_call(
call_id=f"call_{i}",
name=f"tool_{i}",
arguments={"arg": f"value_{i}"},
)
approval_content = Content.from_function_approval_request(
id=f"approval_{i}",
function_call=function_call,
)
_emit_approval_request(approval_content, flow)
assert len(flow.interrupts) == 3
interrupt_ids = {intr["id"] for intr in flow.interrupts}
assert interrupt_ids == {"call_1", "call_2", "call_3"}
def test_resume_to_tool_messages_from_interrupts_payload():
"""Resume payload interrupt responses map to tool messages."""
resume = {
@@ -874,6 +904,81 @@ class TestTextMessageEventBalancing:
assert len(end_events) == 2
async def test_run_agent_stream_accumulates_multiple_confirm_interrupts():
"""Multiple predictive tool calls in a single streaming run should accumulate interrupts.
This exercises the confirm_changes path in run_agent_stream (_agent_run.py),
ensuring that flow.interrupts.append() works correctly for multiple tool calls
and all interrupts appear in the RUN_FINISHED event.
"""
import json
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
predict_config = {
"tasks": {"tool": "generate_tasks", "tool_argument": "steps"},
"notes": {"tool": "generate_notes", "tool_argument": "items"},
}
state_schema = {
"tasks": {"type": "array", "items": {"type": "object"}},
"notes": {"type": "array", "items": {"type": "object"}},
}
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_tasks",
call_id="call-tasks",
arguments=json.dumps({"steps": [{"description": "Task 1"}]}),
),
Content.from_function_call(
name="generate_notes",
call_id="call-notes",
arguments=json.dumps({"items": [{"description": "Note 1"}]}),
),
],
role="assistant",
),
]
stub = StubAgent(updates=updates)
agent = AgentFrameworkAgent(
agent=stub,
state_schema=state_schema,
predict_state_config=predict_config,
require_confirmation=True,
)
payload = {
"thread_id": "thread-multi",
"run_id": "run-multi",
"messages": [{"role": "user", "content": "Generate tasks and notes"}],
"state": {"tasks": [], "notes": []},
}
events = [event async for event in agent.run(payload)]
# Find RUN_FINISHED event and verify multiple interrupts
finished_events = [
e
for e in events
if getattr(e, "type", None) == "RUN_FINISHED"
or getattr(getattr(e, "type", None), "value", None) == "RUN_FINISHED"
]
assert finished_events, f"Expected RUN_FINISHED event. Types: {[getattr(e, 'type', None) for e in events]}"
finished = finished_events[-1]
interrupt = getattr(finished, "interrupt", None)
assert interrupt is not None, "Expected interrupt metadata in RUN_FINISHED"
assert len(interrupt) == 2, f"Expected 2 interrupts (one per tool), got {len(interrupt)}"
# Verify both tool calls are represented in interrupt metadata
interrupt_tool_names = {i["value"]["function_call"]["name"] for i in interrupt}
assert interrupt_tool_names == {"generate_tasks", "generate_notes"}
def test_emit_oauth_consent_request():
"""Test that oauth_consent_request content emits a CustomEvent."""
content = Content.from_oauth_consent_request(
@@ -895,3 +1000,349 @@ def test_emit_oauth_consent_request_no_link():
events = _emit_content(content, flow)
assert len(events) == 0
# ============================================================================
# Tests for MCP tool call, MCP tool result, and text reasoning event emission
# ============================================================================
class TestEmitMcpToolCall:
"""Tests for _emit_mcp_tool_call function."""
def test_produces_start_and_args_events(self):
"""MCP tool call emits ToolCallStart + ToolCallArgs events."""
flow = FlowState()
content = Content.from_mcp_server_tool_call(
call_id="mcp_call_1",
tool_name="search",
server_name="brave",
arguments={"query": "weather"},
)
events = _emit_mcp_tool_call(content, flow)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_START"
assert events[0].tool_call_id == "mcp_call_1"
assert events[0].tool_call_name == "search"
assert events[1].type == "TOOL_CALL_ARGS"
assert events[1].tool_call_id == "mcp_call_1"
assert "weather" in events[1].delta
def test_tracks_in_flow_state(self):
"""MCP tool call is tracked in flow.pending_tool_calls and tool_calls_by_id."""
flow = FlowState()
content = Content.from_mcp_server_tool_call(
call_id="mcp_call_2",
tool_name="get_file",
arguments='{"path": "/tmp/test.txt"}',
)
_emit_mcp_tool_call(content, flow)
assert len(flow.pending_tool_calls) == 1
assert flow.pending_tool_calls[0]["id"] == "mcp_call_2"
assert "mcp_call_2" in flow.tool_calls_by_id
assert flow.tool_calls_by_id["mcp_call_2"]["function"]["name"] == "get_file"
assert flow.tool_calls_by_id["mcp_call_2"]["function"]["arguments"] == '{"path": "/tmp/test.txt"}'
def test_no_server_name_uses_tool_name_only(self):
"""Without server_name, display name is just tool_name."""
flow = FlowState()
content = Content.from_mcp_server_tool_call(
call_id="mcp_call_3",
tool_name="list_files",
)
events = _emit_mcp_tool_call(content, flow)
assert events[0].tool_call_name == "list_files"
def test_no_arguments_skips_args_event(self):
"""No arguments produces only ToolCallStart, no ToolCallArgs."""
flow = FlowState()
content = Content.from_mcp_server_tool_call(
call_id="mcp_call_4",
tool_name="ping",
)
events = _emit_mcp_tool_call(content, flow)
assert len(events) == 1
assert events[0].type == "TOOL_CALL_START"
def test_generates_id_when_missing(self):
"""A tool_call_id is generated when call_id is None."""
flow = FlowState()
content = Content(type="mcp_server_tool_call", tool_name="test_tool")
events = _emit_mcp_tool_call(content, flow)
assert len(events) >= 1
assert events[0].tool_call_id is not None
assert events[0].tool_call_id != ""
assert events[0].tool_call_name == "test_tool"
def test_missing_tool_name_falls_back_to_mcp_tool(self):
"""When tool_name is None, the fallback 'mcp_tool' is used."""
flow = FlowState()
content = Content(type="mcp_server_tool_call")
events = _emit_mcp_tool_call(content, flow)
assert len(events) >= 1
assert events[0].tool_call_name == "mcp_tool"
class TestEmitMcpToolResult:
"""Tests for _emit_mcp_tool_result function."""
def test_produces_end_and_result_events(self):
"""MCP tool result emits ToolCallEnd + ToolCallResult events."""
flow = FlowState()
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_1",
output={"results": [{"title": "Weather", "url": "https://example.com"}]},
)
events = _emit_mcp_tool_result(content, flow)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "mcp_call_1"
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].tool_call_id == "mcp_call_1"
assert "Weather" in events[1].content
def test_tracks_in_flow_state(self):
"""MCP tool result is tracked in flow.tool_results and tool_calls_ended."""
flow = FlowState()
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_5",
output="Success",
)
_emit_mcp_tool_result(content, flow)
assert "mcp_call_5" in flow.tool_calls_ended
assert len(flow.tool_results) == 1
assert flow.tool_results[0]["toolCallId"] == "mcp_call_5"
assert flow.tool_results[0]["content"] == "Success"
def test_no_call_id_returns_empty(self):
"""Missing call_id returns empty events list with a warning."""
flow = FlowState()
content = Content(type="mcp_server_tool_result", output="data")
events = _emit_mcp_tool_result(content, flow)
assert events == []
def test_serializes_non_string_output(self):
"""Non-string output is serialized to JSON."""
flow = FlowState()
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_6",
output={"key": "value", "count": 42},
)
events = _emit_mcp_tool_result(content, flow)
result_event = events[1]
assert isinstance(result_event.content, str)
assert '"key": "value"' in result_event.content
def test_output_none_falls_back_to_empty_string(self):
"""When output is None (default), the result content is an empty string."""
flow = FlowState()
content = Content(type="mcp_server_tool_result", call_id="mcp_call_none")
events = _emit_mcp_tool_result(content, flow)
assert len(events) == 2
assert events[1].type == "TOOL_CALL_RESULT"
assert events[1].content == ""
def test_resets_flow_state_like_emit_tool_result(self):
"""MCP tool result performs same FlowState cleanup as _emit_tool_result."""
flow = FlowState()
flow.tool_call_id = "mcp_call_7"
flow.tool_call_name = "brave/search"
flow.message_id = "open-msg-456"
flow.accumulated_text = "Let me search for that..."
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_7",
output="search results",
)
events = _emit_mcp_tool_result(content, flow)
assert flow.tool_call_id is None
assert flow.tool_call_name is None
assert flow.message_id is None
assert flow.accumulated_text == ""
text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
assert len(text_end_events) == 1
assert text_end_events[0].message_id == "open-msg-456"
def test_no_open_message_skips_text_end(self):
"""MCP tool result without open text message skips TextMessageEndEvent."""
flow = FlowState()
flow.message_id = None
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_8",
output="result",
)
events = _emit_mcp_tool_result(content, flow)
text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
assert len(text_end_events) == 0
def test_predictive_handler_emits_state_snapshot(self):
"""MCP tool result applies pending updates and emits StateSnapshotEvent when predictive_handler is set."""
from unittest.mock import MagicMock
from ag_ui.core import StateSnapshotEvent
flow = FlowState()
flow.current_state = {"doc": "hello"}
content = Content.from_mcp_server_tool_result(
call_id="mcp_call_9",
output="done",
)
handler = MagicMock()
events = _emit_mcp_tool_result(content, flow, predictive_handler=handler)
handler.apply_pending_updates.assert_called_once()
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
assert len(snapshot_events) == 1
assert snapshot_events[0].snapshot == {"doc": "hello"}
class TestEmitTextReasoning:
"""Tests for _emit_text_reasoning function."""
def test_produces_reasoning_events(self):
"""Text reasoning emits the full reasoning event sequence."""
content = Content.from_text_reasoning(
id="reason_1",
text="The user is asking about weather, so I should call the weather tool.",
)
events = _emit_text_reasoning(content)
assert len(events) == 5
assert isinstance(events[0], ReasoningStartEvent)
assert events[0].message_id == "reason_1"
assert isinstance(events[1], ReasoningMessageStartEvent)
assert events[1].message_id == "reason_1"
assert events[1].role == "assistant"
assert isinstance(events[2], ReasoningMessageContentEvent)
assert events[2].message_id == "reason_1"
assert events[2].delta == "The user is asking about weather, so I should call the weather tool."
assert isinstance(events[3], ReasoningMessageEndEvent)
assert events[3].message_id == "reason_1"
assert isinstance(events[4], ReasoningEndEvent)
assert events[4].message_id == "reason_1"
def test_protected_data_emits_encrypted_value_event(self):
"""protected_data is emitted as a ReasoningEncryptedValueEvent."""
content = Content.from_text_reasoning(
id="reason_2",
text="visible reasoning",
protected_data="encrypted metadata",
)
events = _emit_text_reasoning(content)
encrypted_events = [e for e in events if isinstance(e, ReasoningEncryptedValueEvent)]
assert len(encrypted_events) == 1
assert encrypted_events[0].subtype == "message"
assert encrypted_events[0].entity_id == "reason_2"
assert encrypted_events[0].encrypted_value == "encrypted metadata"
def test_protected_data_only_emits_event(self):
"""Content with only protected_data (no text) still emits reasoning events."""
content = Content.from_text_reasoning(
protected_data="encrypted reasoning content",
)
events = _emit_text_reasoning(content)
# Should have start, msg_start, msg_end, encrypted_value, end (no content event)
assert len(events) == 5
assert isinstance(events[0], ReasoningStartEvent)
assert isinstance(events[1], ReasoningMessageStartEvent)
assert isinstance(events[2], ReasoningMessageEndEvent)
assert isinstance(events[3], ReasoningEncryptedValueEvent)
assert events[3].encrypted_value == "encrypted reasoning content"
assert isinstance(events[4], ReasoningEndEvent)
def test_empty_text_and_no_protected_data_returns_empty(self):
"""Empty text and no protected_data returns no events."""
content = Content.from_text_reasoning()
events = _emit_text_reasoning(content)
assert events == []
def test_generates_message_id_when_missing(self):
"""When id is None, a message_id is generated."""
content = Content.from_text_reasoning(text="thinking...")
events = _emit_text_reasoning(content)
assert len(events) == 5
assert events[0].message_id is not None
assert events[0].message_id != ""
# All events share the same message_id
assert events[1].message_id == events[0].message_id
class TestEmitContentMcpRouting:
"""Tests that _emit_content correctly routes MCP and reasoning types."""
def test_routes_mcp_server_tool_call(self):
"""_emit_content dispatches mcp_server_tool_call to _emit_mcp_tool_call."""
flow = FlowState()
content = Content.from_mcp_server_tool_call(
call_id="route_test_1",
tool_name="test_tool",
server_name="test_server",
)
events = _emit_content(content, flow)
assert len(events) >= 1
assert events[0].type == "TOOL_CALL_START"
assert events[0].tool_call_name == "test_tool"
def test_routes_mcp_server_tool_result(self):
"""_emit_content dispatches mcp_server_tool_result to _emit_mcp_tool_result."""
flow = FlowState()
content = Content.from_mcp_server_tool_result(
call_id="route_test_2",
output="result data",
)
events = _emit_content(content, flow)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
def test_routes_text_reasoning(self):
"""_emit_content dispatches text_reasoning to _emit_text_reasoning."""
flow = FlowState()
content = Content.from_text_reasoning(text="I need to think about this...")
events = _emit_content(content, flow)
assert len(events) == 5
assert isinstance(events[0], ReasoningStartEvent)
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock
import pytest
from agent_framework import Agent, tool
from agent_framework_ag_ui._orchestration._tooling import (
@@ -20,7 +21,8 @@ class DummyTool:
class MockMCPTool:
"""Mock MCP tool that simulates connected MCP tool with functions."""
def __init__(self, functions: list[DummyTool], is_connected: bool = True) -> None:
def __init__(self, functions: list[DummyTool], is_connected: bool = True, name: str = "mock-mcp") -> None:
self.name = name
self.functions = functions
self.is_connected = is_connected
@@ -45,11 +47,8 @@ def test_merge_tools_filters_duplicates() -> None:
server = [DummyTool("a"), DummyTool("b")]
client = [DummyTool("b"), DummyTool("c")]
merged = merge_tools(server, client)
assert merged is not None
names = [getattr(t, "name", None) for t in merged]
assert names == ["a", "b", "c"]
with pytest.raises(ValueError, match="Duplicate tool name 'b'"):
merge_tools(server, client)
def test_register_additional_client_tools_assigns_when_configured() -> None:
@@ -131,6 +130,17 @@ def test_collect_server_tools_with_mcp_tools_via_public_property() -> None:
assert len(tools) == 2
def test_collect_server_tools_raises_on_duplicate_agent_and_mcp_tool_names() -> None:
duplicate_tool = DummyTool("regular_tool")
mock_mcp = MockMCPTool([duplicate_tool], is_connected=True, name="docs-mcp")
agent = _create_chat_agent_with_tool("regular_tool")
agent.mcp_tools = [mock_mcp]
with pytest.raises(ValueError, match="Duplicate tool name 'regular_tool'"):
collect_server_tools(agent)
# Additional tests for tooling coverage
@@ -176,11 +186,11 @@ def test_merge_tools_no_client_tools() -> None:
def test_merge_tools_all_duplicates() -> None:
"""merge_tools returns None when all client tools duplicate server tools."""
"""merge_tools raises when client and server tools share a name."""
server = [DummyTool("a"), DummyTool("b")]
client = [DummyTool("a"), DummyTool("b")]
result = merge_tools(server, client)
assert result is None
with pytest.raises(ValueError, match="Duplicate tool name 'a'"):
merge_tools(server, client)
def test_merge_tools_empty_server() -> None:
@@ -208,7 +218,7 @@ def test_merge_tools_with_approval_tools_no_client() -> None:
def test_merge_tools_with_approval_tools_all_duplicates() -> None:
"""merge_tools returns server tools with approval mode even when client duplicates."""
"""merge_tools raises even when a client tool duplicates an approval-gated server tool."""
class ApprovalTool:
def __init__(self, name: str):
@@ -217,7 +227,5 @@ def test_merge_tools_with_approval_tools_all_duplicates() -> None:
server = [ApprovalTool("write_doc")]
client = [DummyTool("write_doc")] # Same name as server
result = merge_tools(server, client)
assert result is not None
assert len(result) == 1
assert result[0].approval_mode == "always_require"
with pytest.raises(ValueError, match="Duplicate tool name 'write_doc'"):
merge_tools(server, client)
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import AnthropicChatOptions, AnthropicClient
from ._chat_client import AnthropicChatOptions, AnthropicClient, RawAnthropicClient
try:
__version__ = importlib.metadata.version(__name__)
@@ -12,5 +12,6 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"RawAnthropicClient",
"__version__",
]
@@ -68,6 +68,7 @@ else:
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"RawAnthropicClient",
"ThinkingConfig",
]
@@ -210,14 +211,24 @@ class AnthropicSettings(TypedDict, total=False):
chat_model_id: str | None
class AnthropicClient(
ChatMiddlewareLayer[AnthropicOptionsT],
FunctionInvocationLayer[AnthropicOptionsT],
ChatTelemetryLayer[AnthropicOptionsT],
class RawAnthropicClient(
BaseChatClient[AnthropicOptionsT],
Generic[AnthropicOptionsT],
):
"""Anthropic Chat client with middleware, telemetry, and function invocation support."""
"""Raw Anthropic chat client without middleware, telemetry, or function invocation support.
Warning:
**This class should not normally be used directly.** It does not include middleware,
telemetry, or function invocation support that you most likely need. If you do use it,
you should consider which additional layers to apply. There is a defined ordering that
you should follow:
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
Use ``AnthropicClient`` instead for a fully-featured client with all layers applied.
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -228,13 +239,11 @@ class AnthropicClient(
model_id: str | None = None,
anthropic_client: AsyncAnthropic | None = None,
additional_beta_flags: list[str] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Anthropic Agent client.
"""Initialize a raw Anthropic client.
Keyword Args:
api_key: The Anthropic API key to use for authentication.
@@ -244,16 +253,14 @@ class AnthropicClient(
For instance if you need to set a different base_url for testing or private deployments.
additional_beta_flags: Additional beta flags to enable on the client.
Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25".
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
additional_properties: Additional properties stored on the client instance.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
Examples:
.. code-block:: python
from agent_framework.anthropic import AnthropicClient
from agent_framework.anthropic import RawAnthropicClient
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
@@ -261,13 +268,13 @@ class AnthropicClient(
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
# Or passing parameters directly
client = AnthropicClient(
client = RawAnthropicClient(
model_id="claude-sonnet-4-5-20250929",
api_key="your_anthropic_api_key",
)
# Or loading from a .env file
client = AnthropicClient(env_file_path="path/to/.env")
client = RawAnthropicClient(env_file_path="path/to/.env")
# Or passing in an existing client
from anthropic import AsyncAnthropic
@@ -275,7 +282,7 @@ class AnthropicClient(
anthropic_client = AsyncAnthropic(
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
)
client = AnthropicClient(
client = RawAnthropicClient(
model_id="claude-sonnet-4-5-20250929",
anthropic_client=anthropic_client,
)
@@ -289,7 +296,7 @@ class AnthropicClient(
my_custom_option: str
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
@@ -319,9 +326,7 @@ class AnthropicClient(
# Initialize parent
super().__init__(
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
additional_properties=additional_properties,
)
# Initialize instance variables
@@ -1376,3 +1381,95 @@ class AnthropicClient(
The service URL for the chat client, or None if not set.
"""
return str(self.anthropic_client.base_url)
class AnthropicClient(
FunctionInvocationLayer[AnthropicOptionsT],
ChatMiddlewareLayer[AnthropicOptionsT],
ChatTelemetryLayer[AnthropicOptionsT],
RawAnthropicClient[AnthropicOptionsT],
Generic[AnthropicOptionsT],
):
"""Anthropic chat client with middleware, telemetry, and function invocation support."""
def __init__(
self,
*,
api_key: str | None = None,
model_id: str | None = None,
anthropic_client: AsyncAnthropic | None = None,
additional_beta_flags: list[str] | None = None,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Anthropic client.
Keyword Args:
api_key: The Anthropic API key to use for authentication.
model_id: The ID of the model to use.
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
This can be used to further configure the client before passing it in.
For instance if you need to set a different base_url for testing or private deployments.
additional_beta_flags: Additional beta flags to enable on the client.
Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25".
additional_properties: Additional properties stored on the client instance.
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
Examples:
.. code-block:: python
from agent_framework.anthropic import AnthropicClient
# Using environment variables
# Set ANTHROPIC_API_KEY=your_anthropic_api_key
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
# Or passing parameters directly
client = AnthropicClient(
model_id="claude-sonnet-4-5-20250929",
api_key="your_anthropic_api_key",
)
# Or loading from a .env file
client = AnthropicClient(env_file_path="path/to/.env")
# Or passing in an existing client
from anthropic import AsyncAnthropic
anthropic_client = AsyncAnthropic(
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
)
client = AnthropicClient(
model_id="claude-sonnet-4-5-20250929",
anthropic_client=anthropic_client,
)
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.anthropic import AnthropicChatOptions
class MyOptions(AnthropicChatOptions, total=False):
my_custom_option: str
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
super().__init__(
api_key=api_key,
model_id=model_id,
anthropic_client=anthropic_client,
additional_beta_flags=additional_beta_flags,
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
+10 -6
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260311"
version = "1.0.0b260319"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"anthropic>=0.70.0,<1",
"agent-framework-core>=1.0.0rc5",
"anthropic>=0.80.0,<0.80.1",
]
[tool.uv]
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
test = "pytest -m \"not integration\" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -6,15 +6,18 @@ from unittest.mock import MagicMock, patch
import pytest
from agent_framework import (
ChatMiddlewareLayer,
ChatOptions,
ChatResponseUpdate,
Content,
FunctionInvocationLayer,
Message,
SupportsChatGetResponse,
tool,
)
from agent_framework._settings import load_settings
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from agent_framework.observability import ChatTelemetryLayer
from anthropic.types.beta import (
BetaMessage,
BetaTextBlock,
@@ -23,7 +26,7 @@ from anthropic.types.beta import (
)
from pydantic import BaseModel, Field
from agent_framework_anthropic import AnthropicClient
from agent_framework_anthropic import AnthropicClient, RawAnthropicClient
from agent_framework_anthropic._chat_client import AnthropicSettings
# Test constants
@@ -64,6 +67,8 @@ def create_test_anthropic_client(
client.additional_beta_flags = []
client.chat_middleware = []
client.function_middleware = []
client._cached_chat_middleware_pipeline = None
client._cached_function_middleware_pipeline = None
client.function_invocation_configuration = normalize_function_invocation_configuration(None)
return client
@@ -117,6 +122,19 @@ def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) ->
assert isinstance(client, SupportsChatGetResponse)
def test_anthropic_client_wraps_raw_client_with_standard_layer_order() -> None:
"""Test AnthropicClient composes the standard public layer stack around the raw client."""
assert issubclass(AnthropicClient, RawAnthropicClient)
mro = AnthropicClient.__mro__
assert mro.index(FunctionInvocationLayer) < mro.index(ChatMiddlewareLayer)
assert mro.index(ChatMiddlewareLayer) < mro.index(ChatTelemetryLayer)
assert mro.index(ChatTelemetryLayer) < mro.index(RawAnthropicClient)
# RawAnthropicClient must not include the convenience layers
assert not issubclass(RawAnthropicClient, FunctionInvocationLayer)
assert not issubclass(RawAnthropicClient, ChatMiddlewareLayer)
assert not issubclass(RawAnthropicClient, ChatTelemetryLayer)
def test_anthropic_client_init_auto_create_client(
anthropic_unit_test_env: dict[str, str],
) -> None:
+10 -6
View File
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260311"
version = "1.0.0b260319"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"azure-search-documents==11.7.0b2",
"agent-framework-core>=1.0.0rc5",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
[tool.uv]
@@ -87,9 +87,13 @@ exclude_dirs = ["tests"]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -17,10 +17,15 @@ from agent_framework_azure_ai_search._context_provider import AzureAISearchConte
@pytest.fixture(autouse=True)
def clear_azure_search_environment(monkeypatch: pytest.MonkeyPatch) -> None:
for key in tuple(os.environ):
if key.startswith("AZURE_SEARCH_"):
monkeypatch.delenv(key, raising=False)
def clear_azure_search_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep tests isolated from ambient Azure Search environment variables."""
for key in (
"AZURE_SEARCH_ENDPOINT",
"AZURE_SEARCH_INDEX_NAME",
"AZURE_SEARCH_KNOWLEDGE_BASE_NAME",
"AZURE_SEARCH_API_KEY",
):
monkeypatch.delenv(key, raising=False)
class MockSearchResults:
@@ -206,8 +206,8 @@ AzureAIAgentOptionsT = TypeVar(
class AzureAIAgentClient(
ChatMiddlewareLayer[AzureAIAgentOptionsT],
FunctionInvocationLayer[AzureAIAgentOptionsT],
ChatMiddlewareLayer[AzureAIAgentOptionsT],
ChatTelemetryLayer[AzureAIAgentOptionsT],
BaseChatClient[AzureAIAgentOptionsT],
Generic[AzureAIAgentOptionsT],
@@ -444,11 +444,11 @@ class AzureAIAgentClient(
model_deployment_name: str | None = None,
credential: AzureCredentialTypes | None = None,
should_cleanup_agent: bool = True,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure AI Agent client.
@@ -471,11 +471,11 @@ class AzureAIAgentClient(
should_cleanup_agent: Whether to cleanup (delete) agents created by this client when
the client is closed or context is exited. Defaults to True. Only affects agents
created by this client instance; existing agents passed via agent_id are never deleted.
additional_properties: Additional properties stored on the client instance.
middleware: Optional sequence of middlewares to include.
function_invocation_configuration: Optional function invocation configuration.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
Examples:
.. code-block:: python
@@ -548,9 +548,9 @@ class AzureAIAgentClient(
# Initialize parent
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
# Initialize instance variables
@@ -97,9 +97,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
you should consider which additional layers to apply. There is a defined ordering that
you should follow:
1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware
2. **FunctionInvocationLayer** - Handles tool/function calling loop
3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
Use ``AzureAIClient`` instead for a fully-featured client with all layers applied.
"""
@@ -119,9 +119,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
credential: AzureCredentialTypes | None = None,
use_latest_version: bool | None = None,
allow_preview: bool | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a bare Azure AI client.
@@ -145,9 +145,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
use_latest_version: Boolean flag that indicates whether to use latest agent version
if it exists in the service.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
additional_properties: Additional properties stored on the client instance.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
Examples:
.. code-block:: python
@@ -217,7 +217,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
# Initialize parent
super().__init__(
**kwargs,
additional_properties=additional_properties,
)
# Initialize instance variables
@@ -1214,8 +1214,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
class AzureAIClient(
ChatMiddlewareLayer[AzureAIClientOptionsT],
FunctionInvocationLayer[AzureAIClientOptionsT],
ChatMiddlewareLayer[AzureAIClientOptionsT],
ChatTelemetryLayer[AzureAIClientOptionsT],
RawAzureAIClient[AzureAIClientOptionsT],
Generic[AzureAIClientOptionsT],
@@ -1243,11 +1243,11 @@ class AzureAIClient(
credential: AzureCredentialTypes | None = None,
use_latest_version: bool | None = None,
allow_preview: bool | None = None,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure AI client with full layer support.
@@ -1268,11 +1268,11 @@ class AzureAIClient(
use_latest_version: Boolean flag that indicates whether to use latest agent version
if it exists in the service.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``
additional_properties: Additional properties stored on the client instance.
middleware: Optional sequence of chat middlewares to include.
function_invocation_configuration: Optional function invocation configuration.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
Examples:
.. code-block:: python
@@ -1319,9 +1319,9 @@ class AzureAIClient(
credential=credential,
use_latest_version=use_latest_version,
allow_preview=allow_preview,
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
**kwargs,
)
@@ -124,9 +124,9 @@ class RawAzureAIInferenceEmbeddingClient(
text_client: EmbeddingsClient | None = None,
image_client: ImageEmbeddingsClient | None = None,
credential: AzureKeyCredential | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a raw Azure AI Inference embedding client."""
settings = load_settings(
@@ -160,7 +160,7 @@ class RawAzureAIInferenceEmbeddingClient(
credential=credential, # type: ignore[arg-type]
)
self._endpoint = resolved_endpoint
super().__init__(**kwargs)
super().__init__(additional_properties=additional_properties)
async def close(self) -> None:
"""Close the underlying SDK clients and release resources."""
@@ -376,9 +376,9 @@ class AzureAIInferenceEmbeddingClient(
image_client: ImageEmbeddingsClient | None = None,
credential: AzureKeyCredential | None = None,
otel_provider_name: str | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure AI Inference embedding client."""
super().__init__(
@@ -389,8 +389,8 @@ class AzureAIInferenceEmbeddingClient(
text_client=text_client,
image_client=image_client,
credential=credential,
additional_properties=additional_properties,
otel_provider_name=otel_provider_name,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
**kwargs,
)
+13 -8
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0rc4"
version = "1.0.0rc5"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,10 +23,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"azure-ai-agents == 1.2.0b5",
"azure-ai-inference>=1.0.0b9",
"aiohttp",
"agent-framework-core>=1.0.0rc5",
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"aiohttp>=3.7.0,<4",
]
[tool.uv]
@@ -85,11 +85,16 @@ exclude_dirs = ["tests"]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests'
[tool.poe.tasks.integration-tests]
help = "Run the package integration test suite."
cmd = """
pytest --import-mode=importlib
-n logical --dist worksteal
@@ -87,6 +87,8 @@ def create_test_azure_ai_chat_client(
client.middleware = None
client.chat_middleware = []
client.function_middleware = []
client._cached_chat_middleware_pipeline = None
client._cached_function_middleware_pipeline = None
client.otel_provider_name = "azure.ai"
client.function_invocation_configuration = {
"enabled": True,
@@ -151,6 +153,10 @@ def test_azure_ai_chat_client_init_auto_create_client(
chat_client.agent_name = None
chat_client.additional_properties = {}
chat_client.middleware = None
chat_client.chat_middleware = []
chat_client.function_middleware = []
chat_client._cached_chat_middleware_pipeline = None
chat_client._cached_function_middleware_pipeline = None
assert chat_client.agents_client is mock_agents_client
assert chat_client.agent_id is None
@@ -124,7 +124,13 @@ class CosmosHistoryProvider(BaseHistoryProvider):
self._database_client = self._cosmos_client.get_database_client(self.database_name)
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]:
async def get_messages(
self,
session_id: str | None,
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Message]:
"""Retrieve stored messages for this session from Azure Cosmos DB."""
await self._ensure_container_proxy()
session_key = self._session_partition_key(session_id)
@@ -157,7 +163,14 @@ class CosmosHistoryProvider(BaseHistoryProvider):
return messages
async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None:
async def save_messages(
self,
session_id: str | None,
messages: Sequence[Message],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Persist messages for this session to Azure Cosmos DB."""
if not messages:
return
+14 -7
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260311"
version = "1.0.0b260319"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"azure-cosmos>=4.9.0",
"agent-framework-core>=1.0.0rc5",
"azure-cosmos>=4.3.0,<5",
]
[tool.uv]
@@ -84,10 +84,17 @@ exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.integration-tests]
help = "Run the package integration test suite."
cmd = "pytest tests/test_cosmos_history_provider.py -m integration"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
+11 -7
View File
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260311"
version = "1.0.0b260319"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,10 +22,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc4",
"agent-framework-core>=1.0.0rc5",
"agent-framework-durabletask",
"azure-functions",
"azure-functions-durable",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
[dependency-groups]
@@ -91,9 +91,13 @@ exclude_dirs = ["tests"]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
test = "pytest -m \"not integration\" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -216,8 +216,8 @@ class BedrockSettings(TypedDict, total=False):
class BedrockChatClient(
ChatMiddlewareLayer[BedrockChatOptionsT],
FunctionInvocationLayer[BedrockChatOptionsT],
ChatMiddlewareLayer[BedrockChatOptionsT],
ChatTelemetryLayer[BedrockChatOptionsT],
BaseChatClient[BedrockChatOptionsT],
Generic[BedrockChatOptionsT],
@@ -236,11 +236,11 @@ class BedrockChatClient(
session_token: str | None = None,
client: BaseClient | None = None,
boto3_session: Boto3Session | None = None,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Create a Bedrock chat client and load AWS credentials.
@@ -252,11 +252,11 @@ class BedrockChatClient(
session_token: Optional AWS session token for temporary credentials.
client: Preconfigured Bedrock runtime client; when omitted a boto3 session is created.
boto3_session: Custom boto3 session used to build the runtime client if provided.
additional_properties: Additional properties stored on the client instance.
middleware: Optional sequence of middlewares to include.
function_invocation_configuration: Optional function invocation configuration
env_file_path: Optional .env file path used by ``BedrockSettings`` to load defaults.
env_file_encoding: Encoding for the optional .env file.
kwargs: Additional arguments forwarded to ``BaseChatClient``.
Examples:
.. code-block:: python
@@ -303,9 +303,9 @@ class BedrockChatClient(
)
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
self.model_id = chat_model_id
self.region = region

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