Compare commits

..
Author SHA1 Message Date
github-actions[bot] 587356c778 Python: chore: upgrade dev dependencies 2026-03-16 15:36:52 +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
f696ac9b57 Python: A2AAgent defaults name/description from AgentCard (#4661)
* Python: A2AAgent defaults name/description from AgentCard

When an AgentCard is provided but name/description are not explicitly
set, A2AAgent now falls back to agent_card.name and agent_card.description.
This avoids redundant duplication when constructing A2AAgent instances,
especially in GroupChat orchestrations where name and description are
essential for routing decisions.

Explicit values still take precedence over card values.

Fixes #4630

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

* Use 'is None' checks instead of truthiness for name/description fallback

Ensures explicitly provided empty strings are not overridden by
agent_card values. Adds test for the empty string edge case.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 00:14:23 +00:00
5e33deff45 Python: Unify tool results as Content items with rich content support (#4331)
* feat(python): allow @tool functions to return rich content (images, audio)

Add support for tool functions to return Content objects that the model can perceive natively. Closes #4272

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

* Anthropic logging + mypy fix

* Address PR review: fix MCP ordering, fold helper into from_function_result, fix Chat client

- Preserve original content order in MCP tool results instead of text-first
- Move _build_function_result logic into Content.from_function_result()
- Chat Completions: inject user message for rich items (API only supports string tool content)
- Update tests for ordering and new from_function_result behavior

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

* Use native Responses API multi-part output, warn+omit for Chat client

- Responses client: put rich items directly in function_call_output's
  output field as list (native API support) instead of user message injection
- Chat client: warn and omit rich items (API doesn't support multi-part
  tool results), matching Ollama/Bedrock pattern
- Unify test image: use sample_image.jpg across all integration tests
- Add Azure OpenAI Responses integration test
- Assert model describes house image to verify perception

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

* Fix lint: remove print statement, wrap long line

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

* Address review feedback: bug fixes, single-pass MCP, unit tests

- Add isinstance guard in from_function_result for non-Content lists
- Fix Anthropic empty tool_content fallback to string result
- Fix Content(type='text', text=None) edge case in parse_result
- Rewrite MCP _parse_tool_result_from_mcp as single-pass (no index counters)
- Add Anthropic unit tests: data image, uri image, unsupported media, all-unsupported
- Add OpenAI Chat unit test: rich items warning and omission
- Add OpenAI Responses unit tests: function_result with/without items
- Add test_types tests: only-rich-items list, non-Content list fallback

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

* Fix pyright errors: add type ignore comments for Any list iteration

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

* Fix mypy/pyright: ensure ToolExecutionException receives str

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

* Fix lint: remove duplicate test_prepare_options_excludes_conversation_id

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

* refactor: unify all tool results into Content items

* addressed copilot comments

* pyright fix

* small fix

* comments

* fix: address Copilot review - warnings, blob safety, dedup

- Add warning logs when rich content is dropped in Claude agent and
  MCP server handlers (matching Chat/Bedrock/Ollama pattern)
- Defensive blob URI construction: wrap plain base64 in data: prefix
- Simplify Chat client _prepare_content_for_openai to use content.result
- Simplify Responses client text-only path, remove redundant nesting
- Add test for plain base64 blob without data: prefix

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

* Fix token double-counting in compaction and address review comments

- Exclude items from _serialize_content() to prevent double-counting
  tokens when items mirrors result in function_result content
- Add rich content warning in GitHub Copilot agent tool handler
- Replace raw Content debug log with concise item count/type summary
- Update stale test comments about FunctionTool.invoke return type

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 22:30:09 +00:00
b6a1315386 fix: omit toolConfig when tool_choice="none" in BedrockChatClient (#4535)
Bedrock's Converse API only accepts "auto", "any", or "tool" as valid
toolChoice keys. The previous code mapped tool_choice="none" to
{"none": {}}, which causes a botocore.exceptions.ParamValidationError.

When tool_choice="none" (set by FunctionInvocationLayer after exhausting
max iterations), the fix now omits toolConfig entirely so the model
won't attempt tool calls.

Added tests for tool_choice="none", "auto", and "required" modes.

Fixes #4529

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-03-12 18:49:08 +00:00
ed2fb3b9dd Python: Fix state snapshot to use deepcopy so nested mutations are detected in durable workflow activities (#4518)
* Use deepcopy for state snapshot to detect nested mutations (#4500)

Replace dict() shallow copy with copy.deepcopy() when snapshotting
workflow state before activity execution. The shallow copy shared
references to nested objects (dicts, lists), so in-place mutations by
executors were reflected in both the snapshot and live state, producing
an empty diff and preventing state updates from propagating to
downstream activities.

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

* Python: Fix state snapshot to use deepcopy so nested mutations are detected in durable workflow activities

Fixes #4500

* Address PR review: remove report, extract testable helpers (#4500)

- Delete REPRODUCTION_REPORT.md (debugging artifact with local paths
  and raw LLM output)
- Extract _create_state_snapshot() and _compute_state_updates() as
  module-level helpers in _app.py so tests exercise the production
  code path
- Update TestStateSnapshotDiff to import and use production helpers
  instead of reimplementing snapshot/diff logic locally

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

* Apply pre-commit auto-fixes

* Add regression tests proving shallow copy bug and deep copy isolation (#4500)

Add two additional tests to TestStateSnapshotDiff:
- test_shallow_copy_would_miss_nested_mutations: reproduces the original
  bug by demonstrating that dict() (shallow copy) misses nested mutations
- test_create_state_snapshot_isolates_nested_objects: verifies the
  production _create_state_snapshot helper creates a true deep copy

These tests ensure a regression back to shallow copy would be caught.

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

* Add integration test exercising full activity code path (#4500)

Address PR review comment: add test_executor_activity_detects_nested_state_mutations
that captures the actual executor_activity function from _setup_executor_activity
and verifies it detects in-place nested mutations. This test would fail if
_app.py line 314 regressed from _create_state_snapshot() back to dict().

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

* Address review feedback for #4518: review comment fixes

* Address PR review feedback for state snapshot diff

- Inline _compute_state_updates logic at call site to reuse precomputed
  original_keys/current_keys sets, avoiding redundant set allocations
- Fix test docstring to describe behavioral regression instead of
  hard-coding a specific line number
- Use SOURCE_ORCHESTRATOR constant in integration test instead of
  literal string

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

* Apply pre-commit auto-fixes

* fix: remove unused _compute_state_updates from _app.py (#4518)

The function was inlined per review comment, making the module-level
helper unused and triggering a pyright reportUnusedFunction error.
Move the helper into the test file where it is still needed for unit
testing the diffing logic.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 18:43:12 +00:00
Peter IbekweandGitHub aa2ff672fb .NET: Fix to emit WorkflowStartedEvent during workflow execution (#4514)
* Fix bug to emit WorkflowStartedEvent during workflow execution

* Updated based on PR comments
2026-03-12 15:45:17 +00:00
bcb55b4a98 .NET: Update A2A, MCP, and system package dependencies (#4647)
* .NET: Update A2A, MCP, and system package dependencies

Update dependency versions:
- A2A/A2A.AspNetCore: 0.3.3-preview → 0.3.4-preview
- ModelContextProtocol: 0.8.0-preview.1 → 1.1.0
- Microsoft.Bcl.AsyncInterfaces: 10.0.3 → 10.0.4
- System.Linq.AsyncEnumerable: 10.0.0 → 10.0.4
- Add Microsoft.Bcl.Memory 10.0.4

Remove internal polyfill extensions now provided by A2A SDK 0.3.4:
- A2AMetadataExtensions (source + tests)
- AdditionalPropertiesDictionaryExtensions (source + tests)

Update DefaultMcpToolHandler to match MCP SDK 1.1.0 API changes where
ImageContentBlock.Data and AudioContentBlock.Data changed from string
to ReadOnlyMemory<byte>.

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

* address pr review comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 14:16:36 +00:00
westeyandGitHub 921c5f9c17 .NET: Include ReasoningEncryptedContent by default when stored output disabled with Responses (#4623)
* Include ReasoningEncryptedContent by default when stored output disabled

* Fix formatting

* Fix formatter
2026-03-12 09:42:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fcdaaff9cd Bump rollup from 4.47.1 to 4.59.0 in /python/packages/devui/frontend (#4338)
Bumps [rollup](https://github.com/rollup/rollup) from 4.47.1 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.47.1...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.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-12 02:42:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
384291ba27 Bump minimatch from 3.1.2 to 3.1.5 in /python/packages/devui/frontend (#4337)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  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-12 02:42:02 +00:00
Tushar MudiGitHubREDMOND\tusharmudi <tusharmudi@microsoft.com>
378bee577e Fix CWE-863: Validate function approval responses in DevUI executor (#4598)
The DevUI /v1/responses endpoint accepts function_approval_response content
without verifying that the request_id corresponds to a real pending approval
request issued by the server. This allows forged approval responses to
execute arbitrary tools with attacker-controlled arguments, bypassing
approval_mode='always_require'.

Changes:
- Track outgoing approval requests in a server-side registry
  (_pending_approvals) keyed by request_id
- Validate incoming approval responses against this registry; reject
  any response whose request_id was not issued by the server
- Use server-stored function_call data (tool name, arguments, call_id)
  instead of client-supplied data when constructing the approval response
- Consume request_ids on use (pop from registry) to prevent replay attacks

Tests:
- 8 new tests covering forged rejection, server-data enforcement,
  anti-replay, multiple independent approvals, and edge cases

Co-authored-by: REDMOND\tusharmudi <tusharmudi@microsoft.com>
2026-03-12 02:34:31 +00:00
18e433fc6d Python: Validate approval responses against server-side pending request registry (#4548)
* Validate approval responses against server-side pending request registry

* improvements

* pin GHCP sdk version to non-breaking for now

* Pin CHCP sdk to LKG.

* really fix GHCP sdk pkg version

* Fix HITL approval validation security gaps and memory leak

- Validate rejected approval responses against pending_approvals registry,
  not just approved ones. Fabricated rejections without a prior request are
  now stripped from messages before reaching the LLM.
- Bound _pending_approvals with OrderedDict + LRU eviction (max 10k) to
  prevent unbounded memory growth from abandoned approval requests.
- Skip registration when function_call.name is None/empty; log warning
  when content.id or function_call is missing at registration time.
- Document pending_approvals parameter in run_agent_stream docstring.
- Add test for fabricated rejection attack scenario.
- Assert pending approval entry is preserved after function name mismatch.
- Pre-populate pending_approvals in rejection test for correct validation.

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

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 23:21:29 +00:00
2f2495e196 Python: Fix function_approval_response extraction in AG-UI workflow path (#4550)
* Extract function_approval_response from workflow messages (#4546)

_extract_responses_from_messages now handles function_approval_response
content in addition to function_result content. Previously, approval
responses sent via the messages field were silently dropped because the
function only checked for content.type == "function_result".

The approval response is keyed by content.id and includes the approved
status, id, and serialized function_call — consistent with how
_coerce_content identifies approval response payloads.

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

* Apply pre-commit auto-fixes

* Fix #4546: Update docstring and add integration tests for message-based approvals

- Update _extract_responses_from_messages docstring to reflect that it
  now handles function_approval_response content in addition to
  function_result content.
- Add integration tests for run_workflow_stream across two turns with
  approval responses provided via messages (function_approvals) rather
  than resume.interrupts, covering both approved and denied scenarios.

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

* Address PR review feedback for #4546

- Use safer 'not .get("interrupt")' assertion instead of 'not in'
  to handle Pydantic v2 model_dump() including keys with None values
- Add unit test for mixed function_result and function_approval_response
  in the same message to TestExtractResponsesFromMessages

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 22:54:16 +00:00
Dmytro StrukandGitHub e5d6e8ca98 Fixed CA1873 warning (#4634) 2026-03-11 15:28:24 -07:00
Tao ChenandGitHub b1866bd279 Python: Fix missing status input for OpenAI responses API (#4626)
* Fix missing status input for OpenAI responses API

* Fix mypy

* Address comments

* Remove raw_rep restore

* Do not set status if it's None
2026-03-11 21:20:23 +00:00
3e03a305f6 Python: Implement annotation-based context compaction (#4469)
* Implement annotation-based context compaction

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

* Handle missing compaction attributes in BaseChatClient

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

* Fix CI typing and bandit issues

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

* Optimize incremental compaction annotation pass

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

* refinement

* Python: add ToolResultCompactionStrategy and CompactionProvider

Add ToolResultCompactionStrategy that collapses older tool-call groups
into short summary messages (e.g. [Tool calls: get_weather]) while
keeping the most recent groups verbatim. This mirrors the .NET
ToolResultCompactionStrategy from PR #4533.

Add CompactionProvider as a context-provider that auto-applies compaction
before each agent turn and stores compacted history in session state
after each turn.

Includes tests and samples for both features.

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

* refinement and alignment with dotnet PR

* updated tool result compaction

* updated tool result compaction

* Python: add ToolResultCompactionStrategy, CompactionProvider, and skip_excluded

- ToolResultCompactionStrategy collapses older tool-call groups into
  [Tool results: func_name: result] summaries with bidirectional tracing
  (same pattern as SummarizationStrategy).
- CompactionProvider as BaseContextProvider with separate before_strategy
  and after_strategy parameters. before_strategy compacts loaded context;
  after_strategy compacts stored history via history_source_id.
- InMemoryHistoryProvider gains skip_excluded flag to filter out messages
  marked as excluded by compaction strategies.
- Tests, samples, and exports updated.

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

* fixed checks

* fix mypy

* Fix: ensure summary messages from both strategies get full compaction annotations

SummarizationStrategy was not calling annotate_message_groups after
inserting its summary message, so the summary lacked core group
annotations (id, kind, index, has_reasoning, _excluded). Added the
missing call. ToolResultCompactionStrategy already had it.

Added tests verifying both strategies produce fully annotated summaries.

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

* updated propagation

* fix mypy

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 19:23:00 +00:00
Dmytro StrukandGitHub 565c0b1623 Updated package versions (#4632) 2026-03-11 19:05:27 +00:00
Dmytro StrukandGitHub 53b0753dfb Prepare RC4 release (#4631) 2026-03-11 18:53:38 +00:00
23ebfbc937 Python: Support skill scripts execution (#4558)
* support skill scripts execution

* fix mixed line endings

* address comments and fix syntax issues

* use few try/except instead of one

* change samples

* validate either script path or script resource is set not both

* fix: separate LLM args from runtime kwargs in skill script execution

* address pr review comments

* address PR review comments

* Update python/packages/core/agent_framework/_skills.py

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

* Update python/packages/core/agent_framework/_skills.py

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

* Update python/packages/core/agent_framework/_skills.py

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

* 1. Fixing the caching bug where parameters_schema would re-inspect on every call when the result was None
   2. Updating the arguments tool description to be more generic (not CLI-specific)

* fix failing tests

* address pr review comments

* address pr review comments

* allow resource function returning any instead of sting

* address PR review comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 18:28:30 +00:00
westeyandGitHub 2f8fd5f82f .NET: Add FinishReason to AgentResponses (#4617)
* Add FinishReason to AgentResponses

* Address PR comments
2026-03-11 14:22:56 +00:00
60d5093421 .NET: SDK Patch Bump (10.0.200) - Address false positive trigger of IL2026/IL3050 diagnostics in hosting projects (#4586)
* Suppress IL2026/IL3050 with targeted pragmas on affected methods

Add #pragma warning disable/restore for IL2026 and IL3050 only around
the specific methods where dotnet format incorrectly adds
[RequiresUnreferencedCode] and [RequiresDynamicCode] attributes despite
proper interceptors configuration in the csproj.

See https://github.com/dotnet/sdk/issues/51136

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

* Upgrade to .NET SDK 10.0.200 and remove IL2026/IL3050 workarounds

Bump global.json to SDK 10.0.200 which fixes the dotnet format bug
that incorrectly added [RequiresUnreferencedCode] and
[RequiresDynamicCode] attributes (https://github.com/dotnet/sdk/issues/51136).

Remove all #pragma warning disable IL2026/IL3050 workarounds from
source files and the --exclude-diagnostics flag from the CI format
workflow.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 10:47:08 +00:00
ChrisGitHubwesteyCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
d3f0c33180 .NET Compaction - Introducing compaction strategies and pipeline (#4533)
* Checkpoint

* Checkpoint

* Stable

* Strategies

* Updated

* Encoding

* Formatting

* Cleanup

* Formatting

* Tests

* Tuning

* Update tests

* Test update

* Remove working solution

* Add sample to solution

* Sample readyme

* Experimental

* Format

* Formatting

* Encoding

* Support IChatReducer

* Sample output formatting

* Initial plan

* Replace CompactingChatClient with MessageCompactionContextProvider

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

* Boundary condition

* Fix encoding

* Fix cast

* Test coverage

* Namespace

* Improvements

* Efficiency

* Cleanup

* Detect service managed conversation

* Fix namespace

* Fix merge

* Fix test expectation

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs

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

* Address PR comments (x1)

* Update comment

* Update comments

* Clean-up

* Format output

* Sync sample comment

* Fix condition

* Adjust data-flow

* Address comments (x2)

* Direct compaction

* Fix summarization content

* Argument check / fix count calculation

* Minor follow-up

* Diagnostics

* Minor updates

* Fix state test

* Fix sliding window perf

* Stable state keys

* Increase size computation

* Formatting

* Add README.md for Agent_Step18_CompactionPipeline sample (#4574)

* Sample comments

* Updated

* Update dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs

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

* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs

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

* Update dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs

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

* Address copilot comments

* Fix namespace

* Comments / convensions

* Prefix `MessageGroup` and `MessageIndex`

* Fix sliding window

* Update dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs

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

* Update dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs

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

* Python alignment

* Fix merge

* Fix equality, readme, and sample

* Readme update and ToolResult fix

* Update dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs

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

* Update dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md

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

* Simplify readme

* Update dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md

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

* Remove example

* Remove unused

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 00:41:39 +00:00
CopilotGitHubcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
97b6c9951a Python: Fix broken link in purview README (504 on Microsoft 365 Dev Program URL) (#4610)
* Initial plan

* Fix broken link in purview README: replace 504-returning dev-program URL with stable learn.microsoft.com URL

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-11 00:12:53 +00:00
e35f530f2e Python: Fix executor_completed event with non-copyable raw_representation in mixed workflows (#4493)
* Python: Fix `executor_completed` event with non-copyable raw_representation in mixed workflows

Fixes #4455

* fix(#4455): use class-level sets for deepcopy field exclusion

- SerializationMixin.__deepcopy__: check type(self).DEFAULT_EXCLUDE
  instead of hardcoding 'raw_representation'
- Content.__deepcopy__: add _SHALLOW_COPY_FIELDS class variable and
  check against it instead of hardcoding
- Fix tautological assertion in test (was always True)
- Add second excluded field to test to verify DEFAULT_EXCLUDE is
  respected generically

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

* Decouple __deepcopy__ from DEFAULT_EXCLUDE in SerializationMixin (#4455)

Introduce _SHALLOW_COPY_FIELDS class variable in SerializationMixin to
separate deep-copy semantics from serialization semantics. Previously,
__deepcopy__ used DEFAULT_EXCLUDE to decide which fields to shallow-copy,
conflating 'not serialized' with 'not safe to deep-copy'. A field added
to DEFAULT_EXCLUDE purely for serialization (e.g. additional_properties)
would be silently shared between original and copy.

- Add _SHALLOW_COPY_FIELDS (default {'raw_representation'}) to
  SerializationMixin, matching the pattern already used by Content
- Update __deepcopy__ to read from _SHALLOW_COPY_FIELDS instead of
  DEFAULT_EXCLUDE
- Add test verifying DEFAULT_EXCLUDE fields are deep-copied unless
  also in _SHALLOW_COPY_FIELDS
- Add test for Content._SHALLOW_COPY_FIELDS identity preservation
- Add test for ChatResponse deep-copying additional_properties

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

* Add test for _SHALLOW_COPY_FIELDS and DEFAULT_EXCLUDE independence

Add test_deepcopy_shallow_copy_fields_override_default_exclude to verify
that a field in both DEFAULT_EXCLUDE and _SHALLOW_COPY_FIELDS is
shallow-copied (controlled by _SHALLOW_COPY_FIELDS), while a field in
DEFAULT_EXCLUDE only is still deep-copied. This addresses review comment
#11 ensuring the two class variables control independent concerns.

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

* Remove unnecessary local variable in __deepcopy__

Inline cls._SHALLOW_COPY_FIELDS directly in the loop check instead of
assigning to a local variable first, per review feedback.

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

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-10 22:20:05 +00:00
Peter IbekweandGitHub a3bfad4791 .NET: Added support for polymorphic type as workflow output (#4485)
* Added support for polymorphic type as workflow output

* Update Linq expression to avoid unnecessary allocations.

* Added caching as per PR comment
2026-03-10 19:45:01 +00:00
Ahmed MuhsinandGitHub 09b3e2e4f0 Python: Prevent pickle deserialization of untrusted HITL HTTP input (#4566)
* fix: prevent pickle deserialization of untrusted HITL input

Add strip_pickle_markers() to sanitize HTTP input before it reaches
pickle.loads() via the checkpoint decoding path. Applied as a 3-layer
defence-in-depth:

1. _app.py: sanitize req.get_json() at the HTTP boundary
2. _workflow.py: sanitize in _deserialize_hitl_response() before decode
3. _serialization.py: sanitize in reconstruct_to_type() as final guard

Any dict containing __pickled__ or __type__ markers from untrusted
sources is replaced with None, blocking arbitrary code execution via
crafted payloads to POST /workflow/respond/{instanceId}/{requestId}.

Includes 12 new unit tests covering the sanitizer and end-to-end
attack prevention.

* refactor: address review concerns for pickle fix

1. Remove deserialize_value() fallback in _deserialize_hitl_response
   untrusted HITL data now returns as-is when no type hint is available,
   never flowing into pickle.loads().

2. Move strip_pickle_markers() out of reconstruct_to_type()  the function
   is general-purpose again; untrusted-data callers are responsible for
   sanitizing first (documented with NOTE comment).

3. Define _PICKLE_MARKER/_TYPE_MARKER as local constants with import-time
   assertions against core's values  decouples from private names while
   failing loudly if core ever changes them.

4. Update tests to reflect new responsibility boundaries.

* fix: simplify warning message and fix ruff RUF001 lint

* fix: suppress pyright reportPrivateUsage on core marker imports

* Lower marker-strip log from warning to debug to avoid log flooding

* Replace assert with RuntimeError for marker sync checks (ruff S101)

* Fix pyright and ruff CI errors in security fix

- Use cast() for dict/list comprehensions in strip_pickle_markers (pyright)
- type: ignore for narrowed dict return in _workflow.py (pyright)
- Simplify marker imports: use core constants directly, remove local copies
- Remove duplicate pyright ignore comment

* Remove duplicate end-to-end test in TestStripPickleMarkers

* Suppress mypy redundant-cast on list cast needed by pyright
2026-03-10 19:29:33 +00:00
Tao ChenandGitHub 55fc882ca8 Python: Fix store=False not overriding client default (#4569)
* Fix store=False not overriding client default

* Address comments

* Fix unit tests

* Fix integration tests

* Fix tests
2026-03-10 18:44:59 +00:00
westeyandGitHub c15f075412 Cleanup unecessary usages of AsIChatClient (#4561) 2026-03-10 15:40:44 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fbcf1444ee Fix Strands Agents documentation links in ADR (#4584)
* Initial plan

* Fix broken Strands Agents documentation links in ADR 0001

Replace 5 broken strandsagents.com URLs (returning 404) with stable
GitHub source code links in docs/decisions/0001-agent-run-response.md.

The Strands Agents docs site restructured from /api-reference/python/
to /api/python/, breaking the old links.

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Update Strands Agents links to use official documentation site

Replace GitHub source links with official strandsagents.com/docs/api/python/
documentation URLs in docs/decisions/0001-agent-run-response.md.

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Update Strands Agents links to use specific documentation URLs

- Streaming: strandsagents.com/docs/user-guide/concepts/streaming/
- Structured output: strandsagents.com/docs/user-guide/concepts/agents/structured-output/
- AgentResult/stop_reason: strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Deduplicate Strands AgentResult link in stop-reason row

Replaced the duplicate hyperlink on `stop_reason` with inline code,
keeping a single AgentResult link to the same URL.

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-03-10 15:17:23 +00:00
1b7668119d .NET: Enable Microsoft.Agents.AI.FoundryMemory for NuGet release (#4559)
* Enable Microsoft.Agents.AI.FoundryMemory for NuGet release

- Remove IsPackable=false override from .csproj to inherit IsPackable=true from nuget-package.props
- Add project to agent-framework-release.slnf for inclusion in build/sign/publish pipeline

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

* Update FoundryMemoryProvider and MemorySearch sample

- StoreAIContextAsync fires UpdateMemoriesAsync immediately (non-accumulation)
- WhenUpdatesCompletedAsync polls last updateId via GetUpdateResultAsync
- Updated FoundryAgents_Step22_MemorySearch sample to create/destroy memory store
  (matching features/foundry-agent-client pattern)

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

* Update FoundryAgents_Step22_MemorySearch sample

- Sample now creates/destroys memory store (self-contained lifecycle)
- Uses WaitForMemoriesUpdateAsync for seeding memories
- Cleanup in finally block deletes both agent and memory store
- Matches features/foundry-agent-client pattern

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-10 13:52:45 +00:00
fd1c66121e .NET: Skip Azure Persistent (V1) flaky CodeInterpreter integration tests (#4583)
* Skip flaky CodeInterpreter integration tests in CI

The CreateAgent_CreatesAgentWithCodeInterpreter tests fail intermittently
because the Azure AI Code Interpreter service sometimes fails to read/execute
uploaded Python files. This causes all 4 integration test jobs to fail
consistently across both platforms (ubuntu/windows) and TFMs (net10.0/net472).

Mark both test variants with Skip to match the convention used by other
flaky tests in the suite (e.g., AzureAIAgentsPersistentStructuredOutputRunTests).

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

* Skip flaky CodeInterpreter integration tests in CI

The CreateAgent_CreatesAgentWithCodeInterpreter tests fail intermittently
because the Azure AI Code Interpreter service sometimes fails to read/execute
uploaded Python files. This causes all 4 integration test jobs to fail
consistently across both platforms (ubuntu/windows) and TFMs (net10.0/net472).

Mark both test variants with Skip to match the convention used by other
flaky tests in the suite (e.g., AzureAIAgentsPersistentStructuredOutputRunTests).

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-10 12:52:40 +00:00
284 changed files with 28355 additions and 3622 deletions
+1 -2
View File
@@ -86,11 +86,10 @@ jobs:
run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }}
# This step will run dotnet format on each of the unique csproj files and fail if any changes are made
# exclude-diagnostics should be removed after fixes for IL2026 and IL3050 are out: https://github.com/dotnet/sdk/issues/51136
- name: Run dotnet format
if: steps.find-csproj.outputs.csproj_files != ''
run: |
for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do
echo "Running dotnet format on $csproj"
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic --exclude-diagnostics IL2026 IL3050"
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic"
done
@@ -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 --project "*"
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@v4
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 --project "*"`
- 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
+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
+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/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (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/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.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](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) 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). |
@@ -1240,3 +1240,10 @@ class AttributionAwareStrategy(CompactionStrategy):
- [ADR-0016: Unifying Context Management with ContextPlugin](0016-python-context-middleware.md) — Parent ADR that established `ContextProvider`, `HistoryProvider`, and `AgentSession` architecture.
- [Context Compaction Limitations Analysis](https://gist.github.com/victordibia/ec3f3baf97345f7e47da025cf55b999f) — Detailed analysis of why current architecture cannot support in-run compaction, with attempted solutions and their failure modes. Option 4 in this ADR corresponds to "Option A: Middleware Access to Mutable Message Source" from that analysis; Options 1-3 correspond to "Option B: Tool Loop Hook", adapted here to a `BaseChatClient` hook instead of `FunctionInvocationConfiguration`.
### Implementation Rollout Note
Implementation is split into two phases:
1. **Phase 1 (PR 1):** runtime compaction foundation in `agent_framework/_compaction.py`, in-run integration, and extensive core tests, plus in-run compaction samples (`basics`, `advanced`, `custom`).
2. **Phase 2 (PR 2):** history/storage compaction (`upsert`-based full replacement), provider support, storage tests, and storage-focused sample (`storage`).
+7 -5
View File
@@ -33,14 +33,15 @@
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.4" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.3" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
@@ -101,13 +102,14 @@
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
<!-- A2A -->
<PackageVersion Include="A2A" Version="0.3.3-preview" />
<PackageVersion Include="A2A.AspNetCore" Version="0.3.3-preview" />
<PackageVersion Include="A2A" Version="0.3.4-preview" />
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.8.0-preview.1" />
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Inference SDKs -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
<!-- Identity -->
+1
View File
@@ -56,6 +56,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
+1
View File
@@ -14,6 +14,7 @@
"src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
"src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
"src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj",
"src\\Microsoft.Agents.AI.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"version": "10.0.200",
"rollForward": "minor",
"allowPrerelease": false
},
+4 -4
View File
@@ -2,11 +2,11 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>3</RCNumber>
<RCNumber>4</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
<GitTag>1.0.0-rc3</GitTag>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260311.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260311.1</PackageVersion>
<GitTag>1.0.0-rc4</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -82,7 +82,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant with access to restaurant information.",
tools: tools);
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -60,7 +60,7 @@ ChatClient openAIChatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent baseAgent = openAIChatClient.AsIChatClient().AsAIAgent(
ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant in charge of approving expenses",
tools: tools);
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OpenAI.Chat;
using RecipeAssistant;
@@ -37,7 +36,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent baseAgent = chatClient.AsIChatClient().AsAIAgent(
AIAgent baseAgent = chatClient.AsAIAgent(
name: "RecipeAgent",
instructions: """
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a CompactionProvider with a compaction pipeline
// as an AIContextProvider for an agent's in-run context management. The pipeline chains multiple
// compaction strategies from gentle to aggressive:
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create a chat client for the agent and a separate one for the summarization strategy.
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
// Define a tool the agent can use, so we can see tool-result compaction in action.
[Description("Look up the current price of a product by name.")]
static string LookupPrice([Description("The product name to look up.")] string productName) =>
productName.ToUpperInvariant() switch
{
"LAPTOP" => "The laptop costs $999.99.",
"KEYBOARD" => "The keyboard costs $79.99.",
"MOUSE" => "The mouse costs $29.99.",
_ => $"Sorry, I don't have pricing for '{productName}'."
};
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
PipelineCompactionStrategy compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries
new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
// Create the agent with a CompactionProvider that uses the compaction pipeline.
AIAgent agent =
agentChatClient
.AsBuilder()
// Note: Adding the CompactionProvider at the builder level means it will be applied to all agents
// built from this builder and will manage context for both agent messages and tool calls.
.UseAIContextProviders(new CompactionProvider(compactionPipeline))
.BuildAIAgent(
new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new()
{
Instructions =
"""
You are a helpful, but long winded, shopping assistant.
Help the user look up prices and compare products.
When responding, Be sure to be extra descriptive and use as
many words as possible without sounding ridiculous.
""",
Tools = [AIFunctionFactory.Create(LookupPrice)]
},
// Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders.
// Specifying compaction at the agent level skips compaction in the function calling loop.
//AIContextProviders = [new CompactionProvider(compactionPipeline)]
});
AgentSession session = await agent.CreateSessionAsync();
// Helper to print chat history size
void PrintChatHistory()
{
if (session.TryGetInMemoryChatHistory(out var history))
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n[Messages: #{history.Count}]\n");
Console.ResetColor();
}
}
// Run a multi-turn conversation with tool calls to exercise the pipeline.
string[] prompts =
[
"What's the price of a laptop?",
"How about a keyboard?",
"And a mouse?",
"Which product is the cheapest?",
"Can you compare the laptop and the keyboard for me?",
"What was the first product I asked about?",
"Thank you!",
];
foreach (string prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
Console.WriteLine(await agent.RunAsync(prompt, session));
PrintChatHistory();
}
@@ -0,0 +1,132 @@
# Compaction Pipeline
This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary.
## What This Sample Shows
- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window
- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one
- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries, activated by a message-count trigger
- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger
- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger
- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget
- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates
## Concepts
### Message groups
The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
| Group kind | Contents |
|---|---|
| `System` | System prompt message(s) |
| `User` | A single user message |
| `ToolCall` | One assistant message with tool calls + the matching tool result messages |
| `AssistantText` | A single assistant text-only message |
| `Summary` | One or more messages summarizing earlier conversation spans, produced by compaction strategies |
`Summary` groups (`CompactionGroupKind.Summary`) are created by compaction strategies (for example, `SummarizationCompactionStrategy`) and do not originate directly from user or assistant messages.
Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs.
### Compaction triggers
A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are:
| Trigger | Activates when… |
|---|---|
| `CompactionTriggers.Always` | Always (unconditional) |
| `CompactionTriggers.Never` | Never (disabled) |
| `CompactionTriggers.MessagesExceed(n)` | Included message count > n |
| `CompactionTriggers.TokensExceed(n)` | Included token count > n |
| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n |
| `CompactionTriggers.GroupsExceed(n)` | Included group count > n |
| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists |
| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) |
| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) |
### Pipeline ordering
Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all.
```
1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label
2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns
3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window
4. TruncationCompactionStrategy – emergency: hard token-budget enforcement
```
## Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and model deployment
- Azure CLI installed and authenticated
**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
## Environment Variables
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Running the Sample
```powershell
cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline
dotnet run
```
## Expected Behavior
The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the full message count so you can observe the pipeline compaction doesn't alter the source conversation.
Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements.
## Customizing the Pipeline
### Using a single strategy
If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline:
```csharp
CompactionProvider provider =
new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20)));
```
### Ad-hoc compaction outside the provider pipeline
`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session:
```csharp
IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
existingMessages);
```
### Using a different model for summarization
The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost:
```csharp
IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient();
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000))
```
### Registering through `ChatClientAgentOptions`
`CompactionProvider` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the `ChatClientBuilder`:
```csharp
AIAgent agent = agentChatClient
.AsBuilder()
.BuildAIAgent(new ChatClientAgentOptions
{
AIContextProviders = [new CompactionProvider(compactionPipeline)]
});
```
This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client.
> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`.
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
## Running the samples from the console
@@ -12,11 +12,8 @@ using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Memory store configuration
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
// The .NET SDK currently only supports using existing memory stores with agents.
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? throw new InvalidOperationException("AZURE_AI_MEMORY_STORE_ID is not set.");
string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}";
const string AgentInstructions = """
You are a helpful assistant that remembers past conversations.
@@ -32,71 +29,57 @@ const string AgentNameNative = "MemorySearchAgent-NATIVE";
string userScope = $"user_{Environment.MachineName}";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
DefaultAzureCredential credential = new();
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
// Ensure the memory store exists and has memories to retrieve.
await EnsureMemoryStoreAsync();
// Create the Memory Search tool configuration
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
{
// Optional: Configure how quickly new memories are indexed (in seconds)
UpdateDelay = 1,
// Optional: Configure search behavior
SearchOptions = new MemorySearchToolOptions
{
// Additional search options can be configured here if needed
}
};
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelay = 0 };
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
AIAgent agent = await CreateAgentWithMEAI();
// AIAgent agent = await CreateAgentWithNativeSDK();
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
// Conversation 1: Share some personal information
Console.WriteLine("User: My name is Alice and I love programming in C#.");
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
// Allow time for memory to be indexed
await Task.Delay(2000);
// Conversation 2: Test if the agent remembers
Console.WriteLine("User: What's my name and what programming language do I prefer?");
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
// Inspect memory search results if available in raw response items
// Note: Memory search tool call results appear as AgentResponseItem types
foreach (var message in response2.Messages)
try
{
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
{
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
foreach (var result in memorySearchResult.Results)
// The agent uses the memory search tool to recall stored information.
Console.WriteLine("User: What's my name and what programming language do I prefer?");
AgentResponse response = await agent.RunAsync("What's my name and what programming language do I prefer?");
Console.WriteLine($"Agent: {response.Messages.LastOrDefault()?.Text}\n");
// Inspect memory search results if available in raw response items.
foreach (var message in response.Messages)
{
if (message.RawRepresentation is MemorySearchToolCallResponseItem memorySearchResult)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
foreach (var result in memorySearchResult.Results)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
}
}
}
}
finally
{
// Cleanup: Delete the agent and memory store.
Console.WriteLine("\nCleaning up...");
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine("Agent deleted.");
await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName);
Console.WriteLine("Memory store deleted.");
}
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
Console.WriteLine("\nCleaning up agent...");
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine("Agent deleted successfully.");
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
// To delete a memory store, use the Azure Portal or Python SDK:
// await project_client.memory_stores.delete(memory_store.name)
// --- Agent Creation Options ---
#pragma warning disable CS8321 // Local function is declared but never used
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
@@ -122,3 +105,36 @@ async Task<AIAgent> CreateAgentWithNativeSDK()
})
);
}
// Helpers — kept at the bottom so the main agent flow above stays clean.
async Task EnsureMemoryStoreAsync()
{
Console.WriteLine($"Creating memory store '{memoryStoreName}'...");
try
{
await aiProjectClient.MemoryStores.GetMemoryStoreAsync(memoryStoreName);
Console.WriteLine("Memory store already exists.");
}
catch (System.ClientModel.ClientResultException ex) when (ex.Status == 404)
{
MemoryStoreDefaultDefinition definition = new(deploymentName, embeddingModelName);
await aiProjectClient.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, "Sample memory store for Memory Search demo");
Console.WriteLine("Memory store created.");
}
Console.WriteLine("Storing memories from a prior conversation...");
MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 };
memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#."));
MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync(
memoryStoreName: memoryStoreName,
options: memoryOptions,
pollingInterval: 500);
if (updateResult.Status == MemoryStoreUpdateStatus.Failed)
{
throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}");
}
Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n");
}
@@ -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);
}
}
@@ -10,7 +10,7 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ChatClient = OpenAI.Chat.ChatClient;
using OpenAI.Chat;
namespace AGUIDojoServer;
@@ -36,7 +36,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "AgenticChat",
description: "A simple chat agent using Azure OpenAI");
}
@@ -45,7 +45,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "BackendToolRenderer",
description: "An agent that can render backend tools using Azure OpenAI",
tools: [AIFunctionFactory.Create(
@@ -59,7 +59,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "HumanInTheLoopAgent",
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
}
@@ -68,7 +68,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "ToolBasedGenerativeUIAgent",
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
}
@@ -76,7 +76,7 @@ internal static class ChatClientAgentFactory
public static AIAgent CreateAgenticUI(JsonSerializerOptions options)
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "AgenticUIAgent",
Description = "An agent that generates agentic user interfaces using Azure OpenAI",
@@ -119,7 +119,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(
var baseAgent = chatClient.AsAIAgent(
name: "SharedStateAgent",
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
@@ -130,7 +130,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "PredictiveStateUpdatesAgent",
Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
@@ -74,7 +74,7 @@ AzureOpenAIClient azureOpenAIClient = new AzureOpenAIClient(
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
// Create AI agent
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful assistant.");
@@ -162,7 +162,7 @@ dotnet run
Edit the instructions in `Server/Program.cs`:
```csharp
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful coding assistant specializing in C# and .NET.");
```
@@ -6,7 +6,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -28,7 +27,7 @@ AzureOpenAIClient azureOpenAIClient = new(
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful assistant.");
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -89,7 +90,6 @@ builder.Services.AddSingleton<AIAgent>(sp =>
return new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent(
name: "ExpenseApprovalAgent",
instructions: "You are an expense approval assistant. You can list pending expenses "
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -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;
@@ -127,6 +127,7 @@ public sealed class A2AAgent : AIAgent
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
@@ -141,6 +142,7 @@ public sealed class A2AAgent : AIAgent
{
AgentId = this.Id,
ResponseId = agentTask.Id,
FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
RawRepresentation = agentTask,
Messages = agentTask.ToChatMessages() ?? [],
ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
@@ -328,6 +330,7 @@ public sealed class A2AAgent : AIAgent
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Role = ChatRole.Assistant,
MessageId = message.MessageId,
@@ -342,6 +345,7 @@ public sealed class A2AAgent : AIAgent
{
AgentId = this.Id,
ResponseId = task.Id,
FinishReason = MapTaskStateToFinishReason(task.Status.State),
RawRepresentation = task,
Role = ChatRole.Assistant,
Contents = task.ToAIContents(),
@@ -365,7 +369,16 @@ public sealed class A2AAgent : AIAgent
responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
responseUpdate.RawRepresentation = artifactUpdateEvent;
}
else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
{
responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
}
return responseUpdate;
}
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
{
return state == TaskState.Completed ? ChatFinishReason.Stop : null;
}
}
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace A2A;
/// <summary>
/// Extension methods for A2A metadata dictionary.
/// </summary>
internal static class A2AMetadataExtensions
{
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is public.
/// </remarks>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (var kvp in metadata)
{
additionalProperties[kvp.Key] = kvp.Value;
}
return additionalProperties;
}
}
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Extension methods for AdditionalPropertiesDictionary.
/// </summary>
internal static class AdditionalPropertiesDictionaryExtensions
{
/// <summary>
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is available.
/// </remarks>
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
{
if (additionalProperties is not { Count: > 0 })
{
return null;
}
var metadata = new Dictionary<string, JsonElement>();
foreach (var kvp in additionalProperties)
{
if (kvp.Value is JsonElement)
{
metadata[kvp.Key] = (JsonElement)kvp.Value!;
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
}
}
@@ -61,6 +61,7 @@ public class AgentResponse
this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.FinishReason = response.FinishReason;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
@@ -84,6 +85,7 @@ public class AgentResponse
this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.FinishReason = response.FinishReason;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
@@ -190,6 +192,21 @@ public class AgentResponse
/// </remarks>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the reason for the agent response finishing.
/// </summary>
/// <value>
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
/// or <see langword="null"/> if the finish reason is not available.
/// </value>
/// <remarks>
/// <para>
/// This property is particularly useful for detecting non-normal completions, such as content filtering
/// or token limit truncation, which may require special handling by the caller.
/// </para>
/// </remarks>
public ChatFinishReason? FinishReason { get; set; }
/// <summary>
/// Gets or sets the resource usage information for generating this response.
/// </summary>
@@ -276,6 +293,7 @@ public class AgentResponse
RawRepresentation = message.RawRepresentation,
Role = message.Role,
FinishReason = this.FinishReason,
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
@@ -38,6 +38,7 @@ public static class AgentResponseExtensions
{
AdditionalProperties = response.AdditionalProperties,
CreatedAt = response.CreatedAt,
FinishReason = response.FinishReason,
Messages = response.Messages,
RawRepresentation = response,
ResponseId = response.ResponseId,
@@ -71,6 +72,7 @@ public static class AgentResponseExtensions
AuthorName = responseUpdate.AuthorName,
Contents = responseUpdate.Contents,
CreatedAt = responseUpdate.CreatedAt,
FinishReason = responseUpdate.FinishReason,
MessageId = responseUpdate.MessageId,
RawRepresentation = responseUpdate,
ResponseId = responseUpdate.ResponseId,
@@ -70,6 +70,7 @@ public class AgentResponseUpdate
this.AuthorName = chatResponseUpdate.AuthorName;
this.Contents = chatResponseUpdate.Contents;
this.CreatedAt = chatResponseUpdate.CreatedAt;
this.FinishReason = chatResponseUpdate.FinishReason;
this.MessageId = chatResponseUpdate.MessageId;
this.RawRepresentation = chatResponseUpdate;
this.ResponseId = chatResponseUpdate.ResponseId;
@@ -153,6 +154,15 @@ public class AgentResponseUpdate
/// </remarks>
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets the reason for the agent response finishing.
/// </summary>
/// <value>
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
/// or <see langword="null"/> if the finish reason is not available or not yet determined (mid-stream).
/// </value>
public ChatFinishReason? FinishReason { get; set; }
/// <inheritdoc/>
public override string ToString() => this.Text;
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
{
_ = Throw.IfNull(messages);
Throw.IfNull(messages);
var state = this._sessionState.GetOrInitializeState(session);
State state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-retrieval reduction if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
@@ -101,7 +102,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
@@ -109,10 +110,16 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-write reduction strategy if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
{
state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
}
/// <summary>
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -4,6 +4,12 @@
### Changed
- 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))
- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
@@ -16,6 +22,8 @@
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
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);
}
}
@@ -13,10 +13,6 @@
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
/// <summary>
/// Extension methods for A2A metadata dictionary.
/// </summary>
internal static class A2AMetadataExtensions
{
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is public.
/// </remarks>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (var kvp in metadata)
{
additionalProperties[kvp.Key] = kvp.Value;
}
return additionalProperties;
}
}
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
/// <summary>
/// Extension methods for AdditionalPropertiesDictionary.
/// </summary>
internal static class AdditionalPropertiesDictionaryExtensions
{
/// <summary>
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is available.
/// </remarks>
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
{
if (additionalProperties is not { Count: > 0 })
{
return null;
}
var metadata = new Dictionary<string, JsonElement>();
foreach (var kvp in additionalProperties)
{
if (kvp.Value is JsonElement)
{
metadata[kvp.Key] = (JsonElement)kvp.Value!;
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
}
}
@@ -72,9 +72,7 @@ internal static class AIAgentChatCompletionsProcessor
await foreach (var agentResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
{
var finishReason = (agentResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
? chatResponseUpdate.FinishReason.ToString()
: "stop";
var finishReason = agentResponseUpdate.FinishReason?.ToString() ?? "stop";
var choiceChunks = new List<ChatCompletionChoiceChunk>();
CompletionUsage? usageDetails = null;
@@ -34,9 +34,7 @@ internal static class AgentResponseExtensions
var chatCompletionChoices = new List<ChatCompletionChoice>();
var index = 0;
var finishReason = (agentResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse)
? chatResponse.FinishReason.ToString()
: "stop"; // "stop" is a natural stop point; returning this by-default
var finishReason = agentResponse.FinishReason?.ToString() ?? ChatFinishReason.Stop.Value; // "stop" is a natural stop point; returning this by-default
foreach (var message in agentResponse.Messages)
{
@@ -100,15 +100,23 @@ public static class OpenAIResponseClientExtensions
/// This corresponds to setting the "store" property in the JSON representation to false.
/// </remarks>
/// <param name="responseClient">The client.</param>
/// <param name="includeReasoningEncryptedContent">
/// Includes an encrypted version of reasoning tokens in reasoning item outputs.
/// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly
/// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program).
/// Defaults to <see langword="true"/>.
/// </param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ResponsesClient"/> that does not store responses for later retrieval.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient)
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient, bool includeReasoningEncryptedContent = true)
{
return Throw.IfNull(responseClient)
.AsIChatClient()
.AsBuilder()
.ConfigureOptions(x => x.RawRepresentationFactory = _ => new CreateResponseOptions() { StoredOutputEnabled = false })
.ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent
? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } }
: new CreateResponseOptions() { StoredOutputEnabled = false })
.Build();
}
}
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -222,31 +223,36 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
}
}
private static AIContent ConvertContentBlock(ContentBlock block)
internal static AIContent ConvertContentBlock(ContentBlock block)
{
return block switch
{
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
private static DataContent CreateDataContent(ReadOnlyMemory<byte> base64Utf8Data, string mediaType)
{
if (string.IsNullOrEmpty(base64Data))
if (base64Utf8Data.IsEmpty)
{
return new DataContent($"data:{mediaType};base64,", mediaType);
}
#if NET8_0_OR_GREATER
string base64 = Encoding.UTF8.GetString(base64Utf8Data.Span);
#else
string base64 = Encoding.UTF8.GetString(base64Utf8Data.ToArray());
#endif
// If it's already a data URI, use it directly
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return new DataContent(base64Data, mediaType);
return new DataContent(base64, mediaType);
}
// Otherwise, construct a data URI from the base64 data
return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
}
}
@@ -72,6 +72,9 @@ internal sealed class LockstepRunEventStream : IRunEventStream
this.RunStatus = RunStatus.Running;
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
// Emit WorkflowStartedEvent to the event stream for consumers
eventSink.Enqueue(new WorkflowStartedEvent());
do
{
while (this._stepRunner.HasUnprocessedMessages &&
@@ -88,9 +88,16 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Run all available supersteps continuously
// Events are streamed out in real-time as they happen via the event handler
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
if (this._stepRunner.HasUnprocessedMessages)
{
await this._stepRunner.RunSuperStepAsync(linkedSource.Token).ConfigureAwait(false);
// Emit WorkflowStartedEvent only when there's actual work to process
// This avoids spurious events on timeout-only loop iterations
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
{
await this._stepRunner.RunSuperStepAsync(linkedSource.Token).ConfigureAwait(false);
}
}
// Update status based on what's waiting
@@ -3,6 +3,7 @@
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@@ -133,7 +134,25 @@ internal sealed class ExecutorProtocol(MessageRouter router, ISet<Type> sendType
public bool CanHandle(Type type) => router.CanHandle(type);
public bool CanOutput(Type type) => this._yieldTypes.Contains(new(type));
private readonly ConcurrentDictionary<Type, bool> _canOutputCache = new();
public bool CanOutput(Type type)
{
return this._canOutputCache.GetOrAdd(type, this.CanOutputCore);
}
private bool CanOutputCore(Type type)
{
foreach (TypeId yieldType in this._yieldTypes)
{
if (yieldType.IsMatchPolymorphic(type))
{
return true;
}
}
return false;
}
public ProtocolDescriptor Describe() => new(this.Router.IncomingTypes, yieldTypes, sendTypes, this.Router.HasCatchAll);
}
@@ -124,6 +124,7 @@ internal sealed class MessageMerger
List<ChatMessage> messages = [];
Dictionary<string, AgentResponse> responses = [];
HashSet<string> agentIds = [];
HashSet<ChatFinishReason> finishReasons = [];
foreach (string responseId in this._mergeStates.Keys)
{
@@ -156,6 +157,11 @@ internal sealed class MessageMerger
createdTimes.Add(response.CreatedAt.Value);
}
if (response.FinishReason.HasValue)
{
finishReasons.Add(response.FinishReason.Value);
}
usage = MergeUsage(usage, response.Usage);
additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties);
}
@@ -182,6 +188,7 @@ internal sealed class MessageMerger
AgentId = primaryAgentId
?? primaryAgentName
?? (agentIds.Count == 1 ? agentIds.First() : null),
FinishReason = finishReasons.Count == 1 ? finishReasons.First() : null,
CreatedAt = DateTimeOffset.UtcNow,
Usage = usage,
AdditionalProperties = additionalProperties
@@ -207,6 +214,7 @@ internal sealed class MessageMerger
AgentId = incoming.AgentId ?? current.AgentId,
AdditionalProperties = MergeProperties(current.AdditionalProperties, incoming.AdditionalProperties),
CreatedAt = incoming.CreatedAt ?? current.CreatedAt,
FinishReason = incoming.FinishReason ?? current.FinishReason,
Messages = current.Messages.Concat(incoming.Messages).ToList(),
ResponseId = current.ResponseId,
RawRepresentation = rawRepresentation,
@@ -55,7 +55,7 @@ public static class ChatClientExtensions
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
_ = chatBuilder.Use((innerClient, services) =>
chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService<ILoggerFactory>();
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Content-based equality comparison for <see cref="ChatMessage"/> instances.
/// </summary>
internal static class ChatMessageContentEquality
{
/// <summary>
/// Determines whether two <see cref="ChatMessage"/> instances represent the same message by content.
/// </summary>
/// <remarks>
/// When both messages define a <see cref="ChatMessage.MessageId"/>, identity is determined solely
/// by that identifier. Otherwise, the comparison falls through to <see cref="ChatMessage.Role"/>,
/// <see cref="ChatMessage.AuthorName"/>, and each item in <see cref="ChatMessage.Contents"/>.
/// </remarks>
internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other)
{
if (ReferenceEquals(message, other))
{
return true;
}
if (message is null || other is null)
{
return false;
}
// A matching MessageId is sufficient.
if (message.MessageId is not null && other.MessageId is not null)
{
return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal);
}
if (message.Role != other.Role)
{
return false;
}
if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal))
{
return false;
}
return ContentsEqual(message.Contents, other.Contents);
}
private static bool ContentsEqual(IList<AIContent> left, IList<AIContent> right)
{
if (left.Count != right.Count)
{
return false;
}
for (int i = 0; i < left.Count; i++)
{
if (!ContentItemEquals(left[i], right[i]))
{
return false;
}
}
return true;
}
private static bool ContentItemEquals(AIContent left, AIContent right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left.GetType() != right.GetType())
{
return false;
}
return (left, right) switch
{
(TextContent a, TextContent b) => TextContentEquals(a, b),
(TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b),
(DataContent a, DataContent b) => DataContentEquals(a, b),
(UriContent a, UriContent b) => UriContentEquals(a, b),
(ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b),
(FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b),
(FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b),
(HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b),
(AIContent a, AIContent b) => a.GetType() == b.GetType(),
};
}
private static bool TextContentEquals(TextContent a, TextContent b) =>
string.Equals(a.Text, b.Text, StringComparison.Ordinal);
private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) =>
string.Equals(a.Text, b.Text, StringComparison.Ordinal) &&
string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal);
private static bool DataContentEquals(DataContent a, DataContent b) =>
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
a.Data.Span.SequenceEqual(b.Data.Span);
private static bool UriContentEquals(UriContent a, UriContent b) =>
Equals(a.Uri, b.Uri) &&
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal);
private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) =>
string.Equals(a.Message, b.Message, StringComparison.Ordinal) &&
string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal) &&
Equals(a.Details, b.Details);
private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) =>
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
ArgumentsEqual(a.Arguments, b.Arguments);
private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) =>
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
Equals(a.Result, b.Result);
private static bool ArgumentsEqual(IDictionary<string, object?>? left, IDictionary<string, object?>? right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left is null || right is null)
{
return false;
}
if (left.Count != right.Count)
{
return false;
}
foreach (KeyValuePair<string, object?> entry in left)
{
if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value))
{
return false;
}
}
return true;
}
private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) =>
string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) &&
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal);
}
@@ -0,0 +1,82 @@
// 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.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that delegates to an <see cref="IChatReducer"/> to reduce the conversation's
/// included messages.
/// </summary>
/// <remarks>
/// <para>
/// This strategy bridges the <see cref="IChatReducer"/> abstraction from <c>Microsoft.Extensions.AI</c>
/// into the compaction pipeline. It collects the currently included messages from the
/// <see cref="CompactionMessageIndex"/>, passes them to the reducer, and rebuilds the index from the
/// reduced message list when the reducer produces fewer messages.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> controls when reduction is attempted.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or message thresholds.
/// </para>
/// <para>
/// Use this strategy when you have an existing <see cref="IChatReducer"/> implementation
/// (such as <c>MessageCountingChatReducer</c>) and want to apply it as part of a
/// <see cref="CompactionStrategy"/> pipeline or as an in-run compaction strategy.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ChatReducerCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
/// <param name="chatReducer">
/// The <see cref="IChatReducer"/> that performs the message reduction.
/// </param>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger)
: base(trigger)
{
this.ChatReducer = Throw.IfNull(chatReducer);
}
/// <summary>
/// Gets the chat reducer used to reduce messages.
/// </summary>
public IChatReducer ChatReducer { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// No need to short-circuit on empty conversations, this is handled by <see cref="CompactionStrategy.CompactAsync"/>.
List<ChatMessage> includedMessages = [.. index.GetIncludedMessages()];
IEnumerable<ChatMessage> reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false);
IList<ChatMessage> reducedMessages = reduced as IList<ChatMessage> ?? [.. reduced];
if (reducedMessages.Count >= includedMessages.Count)
{
return false;
}
// Rebuild the index from the reduced messages
CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
index.Groups.Clear();
foreach (CompactionMessageGroup group in rebuilt.Groups)
{
index.Groups.Add(group);
}
return true;
}
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of a <see cref="CompactionMessageGroup"/>.
/// </summary>
/// <remarks>
/// Message groups are used to classify logically related messages that must be kept together
/// during compaction operations. For example, an assistant message containing tool calls
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum CompactionGroupKind
{
/// <summary>
/// A system message group containing one or more system messages.
/// </summary>
System,
/// <summary>
/// A user message group containing a single user message.
/// </summary>
User,
/// <summary>
/// An assistant message group containing a single assistant text response (no tool calls).
/// </summary>
AssistantText,
/// <summary>
/// An atomic tool call group containing an assistant message with tool calls
/// followed by the corresponding tool result messages.
/// </summary>
/// <remarks>
/// This group must be treated as an atomic unit during compaction. Removing the assistant
/// message without its tool results (or vice versa) will cause LLM API errors.
/// </remarks>
ToolCall,
#pragma warning disable IDE0001 // Simplify Names
/// <summary>
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
/// </summary>
/// <remarks>
/// Summary groups replace previously compacted messages with a condensed representation.
/// They are identified by the <see cref="CompactionMessageGroup.SummaryPropertyKey"/> metadata entry
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
/// </remarks>
#pragma warning restore IDE0001 // Simplify Names
Summary,
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Compaction;
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
/// <summary>
/// Extensions for logging compaction diagnostics.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class CompactionLogMessages
{
/// <summary>
/// Logs when compaction is skipped because the trigger condition was not met.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")]
public static partial void LogCompactionSkipped(
this ILogger logger,
string strategyName);
/// <summary>
/// Logs compaction completion with before/after metrics.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")]
public static partial void LogCompactionCompleted(
this ILogger logger,
string strategyName,
long durationMs,
int beforeMessages,
int afterMessages,
int beforeGroups,
int afterGroups,
int beforeTokens,
int afterTokens);
/// <summary>
/// Logs when the compaction provider skips compaction.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "CompactionProvider skipped: {Reason}.")]
public static partial void LogCompactionProviderSkipped(
this ILogger logger,
string reason);
/// <summary>
/// Logs when the compaction provider begins applying a compaction strategy.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")]
public static partial void LogCompactionProviderApplying(
this ILogger logger,
int messageCount,
string strategyName);
/// <summary>
/// Logs when the compaction provider has applied compaction with result metrics.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")]
public static partial void LogCompactionProviderApplied(
this ILogger logger,
int beforeMessages,
int afterMessages);
/// <summary>
/// Logs when a summarization LLM call is starting.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")]
public static partial void LogSummarizationStarting(
this ILogger logger,
int groupCount,
int messageCount,
string chatClientType);
/// <summary>
/// Logs when a summarization LLM call has completed.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")]
public static partial void LogSummarizationCompleted(
this ILogger logger,
int summaryLength,
int insertIndex);
/// <summary>
/// Logs when a summarization LLM call fails and groups are restored.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Summarization failed for {GroupCount} groups; restoring excluded groups and continuing without compaction. Error: {ErrorMessage}")]
public static partial void LogSummarizationFailed(
this ILogger logger,
int groupCount,
string errorMessage);
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
/// </summary>
/// <remarks>
/// <para>
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
/// containing tool calls and its corresponding tool result messages form a <see cref="CompactionGroupKind.ToolCall"/>
/// group — removing one without the other would cause LLM API errors.
/// </para>
/// <para>
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
/// to indicate it should not be included in the messages sent to the model, while still being preserved
/// for diagnostics, storage, or later re-inclusion.
/// </para>
/// <para>
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
/// so that <see cref="CompactionMessageIndex"/> can efficiently aggregate totals across all or only included groups.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionMessageGroup
{
/// <summary>
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
/// </summary>
/// <remarks>
/// When this key is present with a value of <see langword="true"/>, the message is classified as
/// <see cref="CompactionGroupKind.Summary"/> by <see cref="CompactionMessageIndex.Create"/>.
/// </remarks>
public static readonly string SummaryPropertyKey = "_is_summary";
/// <summary>
/// Initializes a new instance of the <see cref="CompactionMessageGroup"/> class.
/// </summary>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
/// <param name="turnIndex">
/// The user turn this group belongs to, or <see langword="null"/> for <see cref="CompactionGroupKind.System"/>.
/// </param>
[JsonConstructor]
internal CompactionMessageGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
{
this.Kind = kind;
this.Messages = messages;
this.MessageCount = messages.Count;
this.ByteCount = byteCount;
this.TokenCount = tokenCount;
this.TurnIndex = turnIndex;
}
/// <summary>
/// Gets the kind of this message group.
/// </summary>
public CompactionGroupKind Kind { get; }
/// <summary>
/// Gets the messages in this group.
/// </summary>
public IReadOnlyList<ChatMessage> Messages { get; }
/// <summary>
/// Gets the number of messages in this group.
/// </summary>
public int MessageCount { get; }
/// <summary>
/// Gets the total UTF-8 byte count of the text content in this group's messages.
/// </summary>
public int ByteCount { get; }
/// <summary>
/// Gets the estimated or actual token count for this group's messages.
/// </summary>
public int TokenCount { get; }
/// <summary>
/// Gets user turn index this group belongs to, or <see langword="null"/> for groups
/// that precede the first user message (e.g., system messages). A turn index of 0
/// corresponds with any non-system message that precedes the first user message,
/// turn index 1 corresponds with the first user message and its subsequent non-user
/// messages, and so on...
/// </summary>
/// <remarks>
/// A turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
/// non-user, non-system groups until the next user group or end of conversation. System messages
/// (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/> turn index
/// since they never belong to a user turn.
/// </remarks>
public int? TurnIndex { get; }
/// <summary>
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
/// </summary>
/// <remarks>
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
/// but are not included when calling <see cref="CompactionMessageIndex.GetIncludedMessages"/>.
/// </remarks>
public bool IsExcluded { get; set; }
/// <summary>
/// Gets or sets an optional reason explaining why this group was excluded.
/// </summary>
public string? ExcludeReason { get; set; }
}
@@ -0,0 +1,529 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Microsoft.Extensions.AI;
using Microsoft.ML.Tokenizers;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A collection of <see cref="CompactionMessageGroup"/> instances and derived metrics based on a flat list of <see cref="ChatMessage"/> objects.
/// </summary>
/// <remarks>
/// <see cref="CompactionMessageIndex"/> provides structural grouping of messages into logical <see cref="CompactionMessageGroup"/> units. Individual
/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving
/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups,
/// allowing strategies to make informed decisions based on the impact of potential exclusions.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionMessageIndex
{
private int _currentTurn;
private ChatMessage? _lastProcessedMessage;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
public IList<CompactionMessageGroup> Groups { get; }
/// <summary>
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
/// </summary>
public Tokenizer? Tokenizer { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CompactionMessageIndex"/> class with the specified groups.
/// </summary>
/// <param name="groups">The message groups.</param>
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
public CompactionMessageIndex(IList<CompactionMessageGroup> groups, Tokenizer? tokenizer = null)
{
this.Groups = Throw.IfNull(groups, nameof(groups));
this.Tokenizer = tokenizer;
// Restore turn counter and last processed message from the groups
for (int index = groups.Count - 1; index >= 0; --index)
{
if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary)
{
IReadOnlyList<ChatMessage> groupMessages = this.Groups[index].Messages;
this._lastProcessedMessage = groupMessages[^1];
}
if (this.Groups[index].TurnIndex.HasValue)
{
this._currentTurn = this.Groups[index].TurnIndex!.Value;
// Both values restored — no need to keep scanning
if (this._lastProcessedMessage is not null)
{
break;
}
}
}
}
/// <summary>
/// Creates a <see cref="CompactionMessageIndex"/> from a flat list of <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="messages">The messages to group.</param>
/// <param name="tokenizer">
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
/// </param>
/// <returns>A new <see cref="CompactionMessageIndex"/> with messages organized into logical groups.</returns>
/// <remarks>
/// The grouping algorithm:
/// <list type="bullet">
/// <item><description>System messages become <see cref="CompactionGroupKind.System"/> groups.</description></item>
/// <item><description>User messages become <see cref="CompactionGroupKind.User"/> groups.</description></item>
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="CompactionGroupKind.ToolCall"/> groups.</description></item>
/// <item><description>Assistant messages marked with <see cref="CompactionMessageGroup.SummaryPropertyKey"/> become <see cref="CompactionGroupKind.Summary"/> groups.</description></item>
/// <item><description>Assistant messages without tool calls become <see cref="CompactionGroupKind.AssistantText"/> groups.</description></item>
/// </list>
/// </remarks>
internal static CompactionMessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
{
CompactionMessageIndex instance = new([], tokenizer);
instance.AppendFromMessages(messages, 0);
return instance;
}
/// <summary>
/// Incrementally updates the groups with new messages from the conversation.
/// </summary>
/// <param name="allMessages">
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
/// prefix) that was used to create or last update this instance.
/// </param>
/// <remarks>
/// <para>
/// Uses equality on the last processed message to detect changes. Only the messages after that position are
/// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved.
/// </para>
/// <para>
/// If the last processed message is not found (e.g., the message list was replaced entirely
/// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch.
/// </para>
/// <para>
/// If the last message in <paramref name="allMessages"/> matches the last
/// processed message, no work is performed.
/// </para>
/// </remarks>
internal void Update(IList<ChatMessage> allMessages)
{
if (allMessages.Count == 0)
{
this.Groups.Clear();
this._currentTurn = 0;
this._lastProcessedMessage = null;
return;
}
// If the last message is unchanged and the list hasn't shrunk, there is nothing new to process.
if (this._lastProcessedMessage is not null &&
allMessages.Count >= this.RawMessageCount &&
allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage))
{
return;
}
// Walk backwards to locate where we left off.
int foundIndex = -1;
if (this._lastProcessedMessage is not null)
{
for (int i = allMessages.Count - 1; i >= 0; --i)
{
if (allMessages[i].ContentEquals(this._lastProcessedMessage))
{
foundIndex = i;
break;
}
}
}
if (foundIndex < 0)
{
// Last processed message not found — total rebuild.
this.Groups.Clear();
this._currentTurn = 0;
this.AppendFromMessages(allMessages, 0);
return;
}
// Guard against a sliding window that removed messages from the front:
// the number of messages up to (and including) the found position must
// match the number of messages already represented by existing groups.
if (foundIndex + 1 < this.RawMessageCount)
{
// Front of the message list was trimmed — rebuild.
this.Groups.Clear();
this._currentTurn = 0;
this.AppendFromMessages(allMessages, 0);
return;
}
// Process only the delta messages.
this.AppendFromMessages(allMessages, foundIndex + 1);
}
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
{
int index = startIndex;
while (index < messages.Count)
{
ChatMessage message = messages[index];
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
{
List<ChatMessage> groupMessages = [message];
index++;
// Collect all subsequent tool result messages and reasoning-only assistant messages
while (index < messages.Count &&
(messages[index].Role == ChatRole.Tool ||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
{
groupMessages.Add(messages[index]);
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
{
// Reasoning-only assistant messages that precede a tool-call assistant message
// are part of the same atomic tool-call group. Look ahead past consecutive
// reasoning messages to find a possible tool-call message.
int lookahead = index + 1;
while (lookahead < messages.Count &&
messages[lookahead].Role == ChatRole.Assistant &&
HasOnlyReasoning(messages[lookahead]))
{
lookahead++;
}
if (lookahead < messages.Count && messages[lookahead].Role == ChatRole.Assistant && HasToolCalls(messages[lookahead]))
{
// Group all reasoning messages + the tool-call message together
List<ChatMessage> groupMessages = [];
for (int j = index; j <= lookahead; j++)
{
groupMessages.Add(messages[j]);
}
index = lookahead + 1;
// Collect all subsequent tool result messages and reasoning-only assistant messages
while (index < messages.Count &&
(messages[index].Role == ChatRole.Tool ||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
{
groupMessages.Add(messages[index]);
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
if (messages.Count > 0)
{
this._lastProcessedMessage = messages[^1];
}
}
/// <summary>
/// Creates a new <see cref="CompactionMessageGroup"/> with byte and token counts computed using this collection's
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
/// </summary>
/// <param name="index">The zero-based index at which the group should be inserted.</param>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in the group.</param>
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Insert(index, group);
return group;
}
/// <summary>
/// Creates a new <see cref="CompactionMessageGroup"/> with byte and token counts computed using this collection's
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
/// </summary>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in the group.</param>
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Add(group);
return group;
}
/// <summary>
/// Returns only the messages from groups that are not excluded.
/// </summary>
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
public IEnumerable<ChatMessage> GetIncludedMessages() =>
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
/// <summary>
/// Returns all messages from all groups, including excluded ones.
/// </summary>
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
/// <summary>
/// Gets the total number of groups, including excluded ones.
/// </summary>
public int TotalGroupCount => this.Groups.Count;
/// <summary>
/// Gets the total number of messages across all groups, including excluded ones.
/// </summary>
public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
/// </summary>
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all groups, including excluded ones.
/// </summary>
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of groups that are not excluded.
/// </summary>
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
/// <summary>
/// Gets the total number of messages across all included (non-excluded) groups.
/// </summary>
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
/// </summary>
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all included (non-excluded) groups.
/// </summary>
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of user turns across all groups (including those with excluded groups).
/// </summary>
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
/// <summary>
/// Gets the number of user turns that have at least one non-excluded group.
/// </summary>
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
/// <summary>
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
/// </summary>
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
/// <summary>
/// Gets the total number of original messages (that are not summaries).
/// </summary>
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
/// <summary>
/// Returns all groups that belong to the specified user turn.
/// </summary>
/// <param name="turnIndex">The desired turn index.</param>
/// <returns>The groups belonging to the turn, in order.</returns>
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
/// <summary>
/// Computes the UTF-8 byte count for a set of messages across all content types.
/// </summary>
/// <param name="messages">The messages to compute byte count for.</param>
/// <returns>The total UTF-8 byte count of all message content.</returns>
internal static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
IList<AIContent> contents = messages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
total += ComputeContentByteCount(contents[j]);
}
}
return total;
}
/// <summary>
/// Computes the token count for a set of messages using the specified tokenizer.
/// </summary>
/// <param name="messages">The messages to compute token count for.</param>
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
/// <returns>The total token count across all message content.</returns>
/// <remarks>
/// Text-bearing content (<see cref="TextContent"/> and <see cref="TextReasoningContent"/>)
/// is tokenized directly. All other content types estimate tokens as <c>byteCount / 4</c>.
/// </remarks>
internal static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
IList<AIContent> contents = messages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
AIContent content = contents[j];
switch (content)
{
case TextContent text:
if (text.Text is { Length: > 0 } t)
{
total += tokenizer.CountTokens(t);
}
break;
case TextReasoningContent reasoning:
if (reasoning.Text is { Length: > 0 } rt)
{
total += tokenizer.CountTokens(rt);
}
if (reasoning.ProtectedData is { Length: > 0 } pd)
{
total += tokenizer.CountTokens(pd);
}
break;
default:
total += ComputeContentByteCount(content) / 4;
break;
}
}
}
return total;
}
private static int ComputeContentByteCount(AIContent content)
{
switch (content)
{
case TextContent text:
return GetStringByteCount(text.Text);
case TextReasoningContent reasoning:
return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData);
case DataContent data:
return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name);
case UriContent uri:
return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType);
case FunctionCallContent call:
int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name);
if (call.Arguments is not null)
{
foreach (KeyValuePair<string, object?> arg in call.Arguments)
{
callBytes += GetStringByteCount(arg.Key);
callBytes += GetStringByteCount(arg.Value?.ToString());
}
}
return callBytes;
case FunctionResultContent result:
return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString());
case ErrorContent error:
return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details);
case HostedFileContent file:
return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name);
default:
return 0;
}
}
private static int GetStringByteCount(string? value) =>
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
private static CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
{
int byteCount = ComputeByteCount(messages);
int tokenCount = tokenizer is not null
? ComputeTokenCount(messages, tokenizer)
: byteCount / 4;
return new CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
}
private static bool HasToolCalls(ChatMessage message)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent)
{
return true;
}
}
return false;
}
private static bool HasOnlyReasoning(ChatMessage message) =>
message.Contents.All(content => content is TextReasoningContent);
private static bool IsSummaryMessage(ChatMessage message) =>
message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true
&& value is true;
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A <see cref="AIContextProvider"/> that applies a <see cref="CompactionStrategy"/> to compact
/// the message list before each agent invocation.
/// </summary>
/// <remarks>
/// <para>
/// This provider performs in-run compaction by organizing messages into atomic groups (preserving
/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded
/// to the agent's underlying chat client.
/// </para>
/// <para>
/// The <see cref="CompactionProvider"/> can be added to an agent's context provider pipeline
/// via <see cref="ChatClientAgentOptions.AIContextProviders"/> or via <c>UseAIContextProviders</c>
/// on a <see cref="ChatClientBuilder"/> or <see cref="AIAgentBuilder"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionProvider : AIContextProvider
{
private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState<State> _sessionState;
private readonly ILoggerFactory? _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="CompactionProvider"/> class.
/// </summary>
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
/// <param name="stateKey">
/// An optional key used to store the provider state in the <see cref="AgentSession.StateBag"/>. Provide
/// an explicit value if configuring multiple agents with different compaction strategies that will interact
/// in the same session.
/// </param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger for provider diagnostics.
/// When <see langword="null"/>, logging is disabled.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="compactionStrategy"/> is <see langword="null"/>.</exception>
public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
stateKey ??= this._compactionStrategy.GetType().Name;
this.StateKeys = [stateKey];
this._sessionState = new ProviderSessionState<State>(
_ => new State(),
stateKey,
AgentJsonUtilities.DefaultOptions);
this._loggerFactory = loggerFactory;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys { get; }
/// <summary>
/// Applies compaction strategy to the provided message list and returns the compacted messages.
/// This can be used for ad-hoc compaction outside of the provider pipeline.
/// </summary>
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
/// <param name="messages">The messages to compact</param>
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>An enumeration of the compacted <see cref="ChatMessage"/> instances.</returns>
public static async Task<IEnumerable<ChatMessage>> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable<ChatMessage> messages, ILogger? logger = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(compactionStrategy);
Throw.IfNull(messages);
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList);
await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false);
return messageIndex.GetIncludedMessages();
}
/// <summary>
/// Applies the compaction strategy to the accumulated message list before forwarding it to the agent.
/// </summary>
/// <param name="context">Contains the request context including all accumulated messages.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
/// with the compacted message list.
/// </returns>
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke);
ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent);
ILogger logger = loggerFactory.CreateLogger<CompactionProvider>();
AgentSession? session = context.Session;
IEnumerable<ChatMessage>? allMessages = context.AIContext.Messages;
if (session is null || allMessages is null)
{
logger.LogCompactionProviderSkipped("no session or no messages");
return context.AIContext;
}
ChatClientAgentSession? chatClientSession = session.GetService<ChatClientAgentSession>();
if (chatClientSession is not null &&
!string.IsNullOrWhiteSpace(chatClientSession.ConversationId))
{
logger.LogCompactionProviderSkipped("session managed by remote service");
return context.AIContext;
}
List<ChatMessage> messageList = allMessages as List<ChatMessage> ?? [.. allMessages];
State state = this._sessionState.GetOrInitializeState(session);
CompactionMessageIndex messageIndex;
if (state.MessageGroups.Count > 0)
{
// Update existing index with any new messages appended since the last call.
messageIndex = new([.. state.MessageGroups]);
messageIndex.Update(messageList);
}
else
{
// First pass — initialize the message index from scratch.
messageIndex = CompactionMessageIndex.Create(messageList);
}
string strategyName = this._compactionStrategy.GetType().Name;
int beforeMessages = messageIndex.IncludedMessageCount;
logger.LogCompactionProviderApplying(beforeMessages, strategyName);
// Apply compaction
await this._compactionStrategy.CompactAsync(
messageIndex,
loggerFactory.CreateLogger(this._compactionStrategy.GetType()),
cancellationToken).ConfigureAwait(false);
int afterMessages = messageIndex.IncludedMessageCount;
if (afterMessages < beforeMessages)
{
logger.LogCompactionProviderApplied(beforeMessages, afterMessages);
}
// Persist the index
state.MessageGroups.Clear();
state.MessageGroups.AddRange(messageIndex.Groups);
return new AIContext
{
Instructions = context.AIContext.Instructions,
Messages = messageIndex.GetIncludedMessages(),
Tools = context.AIContext.Tools
};
}
private ILoggerFactory GetLoggerFactory(AIAgent agent) =>
this._loggerFactory ??
agent.GetService<IChatClient>()?.GetService<ILoggerFactory>() ??
NullLoggerFactory.Instance;
/// <summary>
/// Represents the persisted state of a <see cref="CompactionProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
internal sealed class State
{
/// <summary>
/// Gets or sets the message index groups used for incremental compaction updates.
/// </summary>
[JsonPropertyName("messagegroups")]
public List<CompactionMessageGroup> MessageGroups { get; set; } = [];
}
}
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Base class for strategies that compact a <see cref="CompactionMessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="CompactionMessageIndex"/> instances, which organize messages
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
/// </para>
/// <para>
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
/// proceed based on current <see cref="CompactionMessageIndex"/> metrics (token count, message count, turn count, etc.).
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
/// the trigger returns <see langword="false"/>.
/// </para>
/// <para>
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
/// meaning compaction stops when the trigger condition would no longer fire.
/// </para>
/// <para>
/// Strategies can be applied at three lifecycle points:
/// <list type="bullet">
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
/// </list>
/// </para>
/// <para>
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="CompactionMessageIndex"/>
/// via <see cref="PipelineCompactionStrategy"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
/// stops as soon as the trigger condition would no longer fire.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
this.Target = target ?? (index => !trigger(index));
}
/// <summary>
/// Gets the trigger predicate that controls when compaction proceeds.
/// </summary>
protected CompactionTrigger Trigger { get; }
/// <summary>
/// Gets the target predicate that controls when compaction stops.
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
/// </summary>
protected CompactionTrigger Target { get; }
/// <summary>
/// Applies the strategy-specific compaction logic to the specified message index.
/// </summary>
/// <remarks>
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
/// to determine when to stop compacting incrementally.
/// </remarks>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="logger">The <see cref="ILogger"/> for emitting compaction diagnostics.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
protected abstract ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken);
/// <summary>
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
/// <see cref="CompactCoreAsync"/> and reports compaction metrics.
/// </summary>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics. When <see langword="null"/>, logging is disabled.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
{
string strategyName = this.GetType().Name;
logger ??= NullLogger.Instance;
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
{
activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
logger.LogCompactionSkipped(strategyName);
return false;
}
activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
int beforeTokens = index.IncludedTokenCount;
int beforeGroups = index.IncludedGroupCount;
int beforeMessages = index.IncludedMessageCount;
Stopwatch stopwatch = Stopwatch.StartNew();
bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
if (compacted)
{
activity?
.SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
.SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
.SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
.SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
.SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
.SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
.SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
logger.LogCompactionCompleted(
strategyName,
stopwatch.ElapsedMilliseconds,
beforeMessages,
index.IncludedMessageCount,
beforeGroups,
index.IncludedGroupCount,
beforeTokens,
index.IncludedTokenCount);
}
return compacted;
}
/// <summary>
/// Ensures the provided value is not a negative number.
/// </summary>
/// <param name="value">The target value.</param>
/// <returns>0 if negative; otherwise the value</returns>
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Provides shared telemetry infrastructure for compaction operations.
/// </summary>
internal static class CompactionTelemetry
{
/// <summary>
/// The <see cref="ActivitySource"/> used to create activities for compaction operations.
/// </summary>
public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName);
/// <summary>
/// Activity names used by compaction tracing.
/// </summary>
public static class ActivityNames
{
public const string Compact = "compaction.compact";
public const string CompactionProviderInvoke = "compaction.provider.invoke";
public const string Summarize = "compaction.summarize";
}
/// <summary>
/// Tag names used on compaction activities.
/// </summary>
public static class Tags
{
public const string Strategy = "compaction.strategy";
public const string Triggered = "compaction.triggered";
public const string Compacted = "compaction.compacted";
public const string BeforeTokens = "compaction.before.tokens";
public const string AfterTokens = "compaction.after.tokens";
public const string BeforeMessages = "compaction.before.messages";
public const string AfterMessages = "compaction.after.messages";
public const string BeforeGroups = "compaction.before.groups";
public const string AfterGroups = "compaction.after.groups";
public const string DurationMs = "compaction.duration_ms";
public const string GroupsSummarized = "compaction.groups_summarized";
public const string SummaryLength = "compaction.summary_length";
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Defines a condition based on <see cref="CompactionMessageIndex"/> metrics used by a <see cref="CompactionStrategy"/>
/// to determine when to trigger compaction and when the target compaction threshold has been met.
/// </summary>
/// <param name="index">An index over conversation messages that provides group, token, message, and turn metrics.</param>
/// <returns><see langword="true"/> to indicate the condition has been met; otherwise <see langword="false"/>.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate bool CompactionTrigger(CompactionMessageIndex index);
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Factory to create <see cref="CompactionTrigger"/> predicates.
/// </summary>
/// <remarks>
/// <para>
/// A <see cref="CompactionTrigger"/> defines a condition based on <see cref="CompactionMessageIndex"/> metrics used
/// by a <see cref="CompactionStrategy"/> to determine when to trigger compaction and when the target
/// compaction threshold has been met.
/// </para>
/// <para>
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class CompactionTriggers
{
/// <summary>
/// Always trigger, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Always =
_ => true;
/// <summary>
/// Never trigger, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Never =
_ => false;
/// <summary>
/// Creates a trigger that fires when the included token count is below the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensBelow(int maxTokens) =>
index => index.IncludedTokenCount < maxTokens;
/// <summary>
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensExceed(int maxTokens) =>
index => index.IncludedTokenCount > maxTokens;
/// <summary>
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
/// </summary>
/// <param name="maxMessages">The message threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
public static CompactionTrigger MessagesExceed(int maxMessages) =>
index => index.IncludedMessageCount > maxMessages;
/// <summary>
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
/// </summary>
/// <param name="maxTurns">The turn threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
/// <remarks>
/// <para>
/// A user turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
/// non-user, non-system groups until the next user group or end of conversation. Each group is assigned
/// a <see cref="CompactionMessageGroup.TurnIndex"/> indicating which user turn it belongs to.
/// System messages (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/>
/// <see cref="CompactionMessageGroup.TurnIndex"/> since they never belong to a user turn.
/// </para>
/// <para>
/// The turn count is the number of distinct values defined by <see cref="CompactionMessageGroup.TurnIndex"/>.
/// </para>
/// </remarks>
public static CompactionTrigger TurnsExceed(int maxTurns) =>
index => index.IncludedTurnCount > maxTurns;
/// <summary>
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
/// </summary>
/// <param name="maxGroups">The group threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
public static CompactionTrigger GroupsExceed(int maxGroups) =>
index => index.IncludedGroupCount > maxGroups;
/// <summary>
/// Creates a trigger that fires when the included message index contains at least one
/// non-excluded <see cref="CompactionGroupKind.ToolCall"/> group.
/// </summary>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
public static CompactionTrigger HasToolCalls() =>
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == CompactionGroupKind.ToolCall);
/// <summary>
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical AND.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (!triggers[i](index))
{
return false;
}
}
return true;
};
/// <summary>
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical OR.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (triggers[i](index))
{
return true;
}
}
return false;
};
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
/// against the same <see cref="CompactionMessageIndex"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
/// such as summarizing older messages first and then truncating to fit a token budget.
/// </para>
/// <para>
/// The pipeline itself always executes while each child strategy evaluates its own
/// <see cref="CompactionStrategy.Trigger"/> independently to decide whether it should compact.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class PipelineCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
: base(CompactionTriggers.Always)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<CompactionStrategy> Strategies { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
bool anyCompacted = false;
foreach (CompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
}
return anyCompacted;
}
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest user turns and their associated response groups
/// to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation (via <see cref="CompactionMessageGroup.TurnIndex"/>) and excludes the oldest turns
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
/// </para>
/// <para>
/// <see cref="MinimumPreservedTurns"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedTurns"/> turns
/// (by <see cref="CompactionMessageGroup.TurnIndex"/>). Groups with a <see cref="CompactionMessageGroup.TurnIndex"/>
/// of <c>0</c> or <see langword="null"/> are always preserved regardless of this setting.
/// </para>
/// <para>
/// This strategy is more predictable than token-based truncation for bounding conversation
/// length, since it operates on logical turn boundaries rather than estimated token counts.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent turns to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 1;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
/// </param>
/// <param name="minimumPreservedTurns">
/// The minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) to preserve.
/// This is a hard floor — compaction will not exclude turns within this range, regardless of the target condition.
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved.
/// </param>
/// <param name="target">
/// 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 SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreservedTurns = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedTurns = EnsureNonNegative(minimumPreservedTurns);
}
/// <summary>
/// Gets the minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved
/// independently of this value.
/// </summary>
public int MinimumPreservedTurns { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Forward pass: pre-index non-system included groups by TurnIndex.
Dictionary<int, List<int>> turnGroups = [];
List<int> turnOrder = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && group.TurnIndex is int turnIndex)
{
if (!turnGroups.TryGetValue(turnIndex, out List<int>? indices))
{
indices = [];
turnGroups[turnIndex] = indices;
turnOrder.Add(turnIndex);
}
indices.Add(i);
}
}
// Backward pass: identify protected turns by TurnIndex.
// TurnIndex = 0 is always protected (non-system messages before first user message).
// TurnIndex = null is always protected (system messages, already excluded from turn tracking).
HashSet<int> protectedTurnIndices = [];
if (turnGroups.ContainsKey(0))
{
protectedTurnIndices.Add(0);
}
// Protect the last MinimumPreservedTurns distinct turns.
int turnsToProtect = Math.Min(this.MinimumPreservedTurns, turnOrder.Count);
for (int i = turnOrder.Count - turnsToProtect; i < turnOrder.Count; i++)
{
protectedTurnIndices.Add(turnOrder[i]);
}
// Exclude turns oldest-first, skipping protected turns, checking target after each turn.
bool compacted = false;
for (int t = 0; t < turnOrder.Count; t++)
{
int currentTurnIndex = turnOrder[t];
if (protectedTurnIndices.Contains(currentTurnIndex))
{
continue;
}
List<int> groupIndices = turnGroups[currentTurnIndex];
for (int g = 0; g < groupIndices.Count; g++)
{
int idx = groupIndices[g];
index.Groups[idx].IsExcluded = true;
index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
}
compacted = true;
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
}
@@ -0,0 +1,207 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
/// replacing them with a single summary message that preserves key facts and context.
/// </summary>
/// <remarks>
/// <para>
/// This strategy protects system messages and the most recent <see cref="MinimumPreservedGroups"/>
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
/// for summarization. The resulting summary replaces those messages as a single assistant message
/// with <see cref="CompactionGroupKind.Summary"/>.
/// </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>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SummarizationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default summarization prompt used when none is provided.
/// </summary>
public const string DefaultSummarizationPrompt =
"""
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
- Key facts, decisions, and user preferences
- Important context needed for future turns
- Tool call outcomes and their significance
Omit pleasantries and redundant exchanges. Be factual and brief.
""";
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 8;
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not summarize groups beyond this limit,
/// regardless of the target condition. Defaults to 8, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// <see cref="DefaultSummarizationPrompt"/> is used.
/// </param>
/// <param name="target">
/// 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 SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
string? summarizationPrompt = null,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.ChatClient = Throw.IfNull(chatClient);
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
}
/// <summary>
/// Gets the chat client used for generating summaries.
/// </summary>
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <summary>
/// Gets the prompt used when requesting summaries from the chat client.
/// </summary>
public string SummarizationPrompt { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Count non-system, non-excluded groups to determine which are protected
int nonSystemIncludedCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
nonSystemIncludedCount++;
}
}
int protectedFromEnd = Math.Min(this.MinimumPreservedGroups, nonSystemIncludedCount);
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
if (maxSummarizable <= 0)
{
return false;
}
// Mark oldest non-system groups for summarization one at a time until the target is met.
// Track which groups were excluded so we can restore them if the LLM call fails.
List<ChatMessage> summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)];
List<CompactionMessageGroup> excludedGroups = [];
int insertIndex = -1;
for (int i = 0; i < index.Groups.Count && excludedGroups.Count < maxSummarizable; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
{
continue;
}
if (insertIndex < 0)
{
insertIndex = i;
}
// Collect messages from this group for summarization
summarizationMessages.AddRange(group.Messages);
group.IsExcluded = true;
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
excludedGroups.Add(group);
// Stop marking when target condition is met
if (this.Target(index))
{
break;
}
}
// Generate summary using the chat client (single LLM call for all marked groups)
int summarized = excludedGroups.Count;
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name);
}
using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize);
summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized);
ChatResponse response;
try
{
response = await this.ChatClient.GetResponseAsync(
summarizationMessages,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Restore excluded groups so the conversation is not left in an inconsistent state
for (int i = 0; i < excludedGroups.Count; i++)
{
excludedGroups[i].IsExcluded = false;
excludedGroups[i].ExcludeReason = null;
}
logger.LogSummarizationFailed(summarized, ex.Message);
return false;
}
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
summarizeActivity?.SetTag(CompactionTelemetry.Tags.SummaryLength, summaryText.Length);
// Insert a summary group at the position of the first summarized group
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(insertIndex, CompactionGroupKind.Summary, [summaryMessage]);
logger.LogSummarizationCompleted(summaryText.Length, insertIndex);
return true;
}
}
@@ -0,0 +1,234 @@
// 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.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that collapses old tool call groups into single concise assistant
/// messages, removing the detailed tool results while preserving a record of which tools were called
/// and what they returned.
/// </summary>
/// <remarks>
/// <para>
/// This is the gentlest compaction strategy — it does not remove any user messages or
/// plain assistant responses. It only targets <see cref="CompactionGroupKind.ToolCall"/>
/// groups outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message in a YAML-like format:
/// <code>
/// [Tool Calls]
/// get_weather:
/// - Sunny and 72°F
/// search_docs:
/// - Found 3 docs
/// </code>
/// </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>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ToolResultCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 16;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not collapse groups beyond this limit,
/// regardless of the target condition.
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
/// </param>
/// <param name="target">
/// 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)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
}
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Identify protected groups: the N most-recent non-system, non-excluded groups
List<int> nonSystemIncludedIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
nonSystemIncludedIndices.Add(i);
}
}
int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups);
HashSet<int> protectedGroupIndices = [];
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
{
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
}
// Collect eligible tool groups in order (oldest first)
List<int> eligibleIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
{
eligibleIndices.Add(i);
}
}
if (eligibleIndices.Count == 0)
{
return new ValueTask<bool>(false);
}
// Collapse one tool group at a time from oldest, re-checking target after each
bool compacted = false;
int offset = 0;
for (int e = 0; e < eligibleIndices.Count; e++)
{
int idx = eligibleIndices[e] + offset;
CompactionMessageGroup group = index.Groups[idx];
string summary = BuildToolCallSummary(group);
// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex);
offset++; // Each insertion shifts subsequent indices by 1
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
/// <summary>
/// Builds a concise summary string for a tool call group, including tool names,
/// results, and deduplication counts for repeated tool names.
/// </summary>
private static string BuildToolCallSummary(CompactionMessageGroup group)
{
// Collect function calls (callId, name) and results (callId → result text)
List<(string CallId, string Name)> functionCalls = [];
Dictionary<string, string> resultsByCallId = new();
List<string> plainTextResults = [];
foreach (ChatMessage message in group.Messages)
{
if (message.Contents is null)
{
continue;
}
bool hasFunctionResult = false;
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent fcc)
{
functionCalls.Add((fcc.CallId, fcc.Name));
}
else if (content is FunctionResultContent frc && frc.CallId is not null)
{
resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty;
hasFunctionResult = true;
}
}
// Collect plain text from Tool-role messages that lack FunctionResultContent
if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text)
{
plainTextResults.Add(text);
}
}
// Match function calls to their results using CallId or positional fallback,
// grouping by tool name while preserving first-seen order.
int plainTextIdx = 0;
List<string> orderedNames = [];
Dictionary<string, List<string>> groupedResults = new();
foreach ((string callId, string name) in functionCalls)
{
if (!groupedResults.TryGetValue(name, out _))
{
orderedNames.Add(name);
groupedResults[name] = [];
}
string? result = null;
if (resultsByCallId.TryGetValue(callId, out string? matchedResult))
{
result = matchedResult;
}
else if (plainTextIdx < plainTextResults.Count)
{
result = plainTextResults[plainTextIdx++];
}
if (!string.IsNullOrEmpty(result))
{
groupedResults[name].Add(result);
}
}
// Format as YAML-like block with [Tool Calls] header
List<string> lines = ["[Tool Calls]"];
foreach (string name in orderedNames)
{
List<string> results = groupedResults[name];
lines.Add($"{name}:");
if (results.Count > 0)
{
foreach (string result in results)
{
lines.Add($" - {result}");
}
}
}
return string.Join("\n", lines);
}
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest non-system message groups,
/// keeping at least <see cref="MinimumPreservedGroups"/> most-recent groups intact.
/// </summary>
/// <remarks>
/// <para>
/// This strategy preserves system messages and removes the oldest non-system message groups first.
/// It respects atomic group boundaries — an assistant message with tool calls and its
/// corresponding tool result messages are always removed together.
/// </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>
/// <para>
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TruncationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 32;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not remove groups beyond this limit,
/// regardless of the target condition.
/// </param>
/// <param name="target">
/// 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 TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
}
/// <summary>
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Count removable (non-system, non-excluded) groups
int removableCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
removableCount++;
}
}
int maxRemovable = removableCount - this.MinimumPreservedGroups;
if (maxRemovable <= 0)
{
return new ValueTask<bool>(false);
}
// Exclude oldest non-system groups one at a time, re-checking target after each
bool compacted = false;
int removed = 0;
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
{
continue;
}
group.IsExcluded = true;
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
}
@@ -18,10 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -36,7 +40,7 @@
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
</ItemGroup>
</Project>
@@ -14,6 +14,8 @@ namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentCreateTests
{
private const string SkipCodeInterpreterReason = "Azure AI Code Interpreter intermittently fails to execute uploaded files in CI";
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
[Theory]
@@ -131,11 +133,11 @@ public class AzureAIAgentsPersistentCreateTests
}
}
[Fact]
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_ChatClientAgentOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithChatClientAgentOptionsAsync");
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_FoundryOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithFoundryOptionsAsync");
@@ -126,6 +126,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Single(result.Messages);
Assert.Equal(ChatRole.Assistant, result.Messages[0].Role);
Assert.Equal("Hello! How can I help you today?", result.Messages[0].Text);
Assert.Equal(ChatFinishReason.Stop, result.FinishReason);
}
[Fact]
@@ -249,8 +250,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal("stream-1", updates[0].MessageId);
Assert.Equal(this._agent.Id, updates[0].AgentId);
Assert.Equal("stream-1", updates[0].ResponseId);
Assert.NotNull(updates[0].RawRepresentation);
Assert.Equal(ChatFinishReason.Stop, updates[0].FinishReason);
Assert.IsType<AgentMessage>(updates[0].RawRepresentation);
Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId);
}
@@ -501,8 +501,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.NotNull(result);
Assert.Equal(this._agent.Id, result.AgentId);
Assert.Equal("task-789", result.ResponseId);
Assert.NotNull(result.RawRepresentation);
Assert.Null(result.FinishReason);
Assert.IsType<AgentTask>(result.RawRepresentation);
Assert.Equal("task-789", ((AgentTask)result.RawRepresentation).Id);
@@ -552,6 +551,15 @@ public sealed class A2AAgentTests : IDisposable
{
Assert.Null(result.ContinuationToken);
}
if (taskState is TaskState.Completed)
{
Assert.Equal(ChatFinishReason.Stop, result.FinishReason);
}
else
{
Assert.Null(result.FinishReason);
}
}
[Fact]
@@ -661,6 +669,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(MessageId, update0.ResponseId);
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Equal(MessageText, update0.Text);
Assert.Equal(ChatFinishReason.Stop, update0.FinishReason);
Assert.IsType<AgentMessage>(update0.RawRepresentation);
Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId);
}
@@ -702,6 +711,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(ChatRole.Assistant, update0.Role);
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Null(update0.FinishReason);
Assert.IsType<AgentTask>(update0.RawRepresentation);
Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id);
@@ -741,6 +751,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(ChatRole.Assistant, update0.Role);
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Null(update0.FinishReason);
Assert.IsType<TaskStatusUpdateEvent>(update0.RawRepresentation);
// Assert - session should be updated with context and task IDs
@@ -784,6 +795,7 @@ public sealed class A2AAgentTests : IDisposable
Assert.Equal(ChatRole.Assistant, update0.Role);
Assert.Equal(TaskId, update0.ResponseId);
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Null(update0.FinishReason);
Assert.IsType<TaskArtifactUpdateEvent>(update0.RawRepresentation);
// Assert - artifact content should be in the update
@@ -1,67 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using A2A;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AMetadataExtensions"/> class.
/// </summary>
public sealed class A2AMetadataExtensionsTests
{
[Fact]
public void ToAdditionalProperties_WithNullMetadata_ReturnsNull()
{
// Arrange
Dictionary<string, JsonElement>? metadata = null;
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.Null(result);
}
[Fact]
public void ToAdditionalProperties_WithEmptyMetadata_ReturnsNull()
{
// Arrange
var metadata = new Dictionary<string, JsonElement>();
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.Null(result);
}
[Fact]
public void ToAdditionalProperties_WithMultipleProperties_ReturnsAdditionalPropertiesDictionaryWithAllProperties()
{
// Arrange
var metadata = new Dictionary<string, JsonElement>
{
{ "stringKey", JsonSerializer.SerializeToElement("stringValue") },
{ "numberKey", JsonSerializer.SerializeToElement(42) },
{ "booleanKey", JsonSerializer.SerializeToElement(true) }
};
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", ((JsonElement)result["stringKey"]!).GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, ((JsonElement)result["numberKey"]!).GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(((JsonElement)result["booleanKey"]!).GetBoolean());
}
}
@@ -1,186 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
/// </summary>
public sealed class AdditionalPropertiesDictionaryExtensionsTests
{
[Fact]
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary? additionalProperties = null;
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = [];
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
}
[Fact]
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "numberKey", 42 }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
}
[Fact]
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" },
{ "numberKey", 42 },
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
{
// Arrange
int[] arrayValue = [1, 2, 3];
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "arrayKey", arrayValue }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("arrayKey"));
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
Assert.Equal(3, result["arrayKey"].GetArrayLength());
}
[Fact]
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "nullKey", null! }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("nullKey"));
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
}
[Fact]
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
{
// Arrange
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "jsonElementKey", jsonElement }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("jsonElementKey"));
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
}
}
@@ -53,6 +53,7 @@ public class AgentResponseTests
{
AdditionalProperties = [],
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
FinishReason = ChatFinishReason.ContentFilter,
Messages = [new(ChatRole.Assistant, "This is a test message.")],
RawRepresentation = new object(),
ResponseId = "responseId",
@@ -63,6 +64,7 @@ public class AgentResponseTests
AgentResponse response = new(chatResponse);
Assert.Same(chatResponse.AdditionalProperties, response.AdditionalProperties);
Assert.Equal(chatResponse.CreatedAt, response.CreatedAt);
Assert.Equal(chatResponse.FinishReason, response.FinishReason);
Assert.Same(chatResponse.Messages, response.Messages);
Assert.Equal(chatResponse.ResponseId, response.ResponseId);
Assert.Same(chatResponse, response.RawRepresentation as ChatResponse);
@@ -105,6 +107,10 @@ public class AgentResponseTests
Assert.Null(response.ContinuationToken);
response.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), response.ContinuationToken);
Assert.Null(response.FinishReason);
response.FinishReason = ChatFinishReason.Length;
Assert.Equal(ChatFinishReason.Length, response.FinishReason);
}
[Fact]
@@ -188,6 +194,7 @@ public class AgentResponseTests
ResponseId = "12345",
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
FinishReason = ChatFinishReason.ContentFilter,
Usage = new UsageDetails
{
TotalTokenCount = 100
@@ -205,6 +212,7 @@ public class AgentResponseTests
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
Assert.Equal("customRole", update0.Role?.Value);
Assert.Equal("Text", update0.Text);
Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason);
AgentResponseUpdate update1 = updates[1];
Assert.Equal("value1", update1.AdditionalProperties?["key1"]);
@@ -334,6 +334,7 @@ public class AgentResponseUpdateExtensionsTests
{
ResponseId = "test-response-id",
CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero),
FinishReason = ChatFinishReason.ContentFilter,
Usage = new UsageDetails { TotalTokenCount = 50 },
AdditionalProperties = new() { ["key"] = "value" },
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
@@ -346,6 +347,7 @@ public class AgentResponseUpdateExtensionsTests
Assert.NotNull(result);
Assert.Equal("test-response-id", result.ResponseId);
Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt);
Assert.Equal(ChatFinishReason.ContentFilter, result.FinishReason);
Assert.Same(agentResponse.Messages, result.Messages);
Assert.Same(agentResponse, result.RawRepresentation);
Assert.Same(agentResponse.Usage, result.Usage);
@@ -392,6 +394,7 @@ public class AgentResponseUpdateExtensionsTests
ResponseId = "update-id",
MessageId = "message-id",
CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero),
FinishReason = ChatFinishReason.ToolCalls,
AdditionalProperties = new() { ["key"] = "value" },
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
};
@@ -405,6 +408,7 @@ public class AgentResponseUpdateExtensionsTests
Assert.Equal("update-id", result.ResponseId);
Assert.Equal("message-id", result.MessageId);
Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt);
Assert.Equal(ChatFinishReason.ToolCalls, result.FinishReason);
Assert.Equal(ChatRole.Assistant, result.Role);
Assert.Same(agentResponseUpdate.Contents, result.Contents);
Assert.Same(agentResponseUpdate, result.RawRepresentation);
@@ -24,6 +24,7 @@ public class AgentResponseUpdateTests
Assert.Null(update.CreatedAt);
Assert.Equal(string.Empty, update.ToString());
Assert.Null(update.ContinuationToken);
Assert.Null(update.FinishReason);
}
[Fact]
@@ -50,6 +51,7 @@ public class AgentResponseUpdateTests
Assert.Equal(chatResponseUpdate.AuthorName, response.AuthorName);
Assert.Same(chatResponseUpdate.Contents, response.Contents);
Assert.Equal(chatResponseUpdate.CreatedAt, response.CreatedAt);
Assert.Equal(chatResponseUpdate.FinishReason, response.FinishReason);
Assert.Equal(chatResponseUpdate.MessageId, response.MessageId);
Assert.Same(chatResponseUpdate, response.RawRepresentation as ChatResponseUpdate);
Assert.Equal(chatResponseUpdate.ResponseId, response.ResponseId);
@@ -109,6 +111,10 @@ public class AgentResponseUpdateTests
Assert.Null(update.ContinuationToken);
update.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), update.ContinuationToken);
Assert.Null(update.FinishReason);
update.FinishReason = ChatFinishReason.ToolCalls;
Assert.Equal(ChatFinishReason.ToolCalls, update.FinishReason);
}
[Fact]
@@ -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);
}
}
@@ -1,187 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
/// <summary>
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
/// </summary>
public sealed class AdditionalPropertiesDictionaryExtensionsTests
{
[Fact]
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary? additionalProperties = null;
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = [];
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
}
[Fact]
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "numberKey", 42 }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
}
[Fact]
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" },
{ "numberKey", 42 },
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
{
// Arrange
int[] arrayValue = [1, 2, 3];
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "arrayKey", arrayValue }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("arrayKey"));
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
Assert.Equal(3, result["arrayKey"].GetArrayLength());
}
[Fact]
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "nullKey", null! }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("nullKey"));
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
}
[Fact]
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
{
// Arrange
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "jsonElementKey", jsonElement }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("jsonElementKey"));
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
}
}
@@ -291,6 +291,85 @@ public sealed class OpenAIResponseClientExtensionsTests
Assert.Same(responseClient, innerClient);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false
/// wraps the original ResponsesClient, which remains accessible via the service chain.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
// Assert - the inner ResponsesClient should be accessible via GetService
var innerClient = chatClient.GetService<ResponsesClient>();
Assert.NotNull(innerClient);
Assert.Same(responseClient, innerClient);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true)
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true);
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false
/// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent()
{
// Arrange
var responseClient = new TestOpenAIResponseClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// A simple test IServiceProvider implementation for testing.
/// </summary>
@@ -309,4 +388,24 @@ public sealed class OpenAIResponseClientExtensionsTests
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
return property?.GetValue(client) as IServiceProvider;
}
/// <summary>
/// Extracts the <see cref="CreateResponseOptions"/> produced by the ConfigureOptions pipeline
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
{
// The ConfigureOptionsChatClient stores the configure action in a private field.
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(configureField);
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
Assert.NotNull(configureAction);
var options = new ChatOptions();
configureAction(options);
Assert.NotNull(options.RawRepresentationFactory);
return options.RawRepresentationFactory(chatClient) as CreateResponseOptions;
}
}
@@ -0,0 +1,518 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ChatMessageContentEquality"/> extension methods.
/// </summary>
public class ChatMessageContentEqualityTests
{
#region Null and reference handling
[Fact]
public void BothNullReturnsTrue()
{
ChatMessage? a = null;
ChatMessage? b = null;
Assert.True(a.ContentEquals(b));
}
[Fact]
public void LeftNullReturnsFalse()
{
ChatMessage? a = null;
ChatMessage b = new(ChatRole.User, "Hello");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void RightNullReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage? b = null;
Assert.False(a.ContentEquals(b));
}
[Fact]
public void SameReferenceReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(a));
}
#endregion
#region MessageId shortcut
[Fact]
public void MatchingMessageIdReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void MatchingMessageIdSufficientDespiteDifferentContent()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.Assistant, "Goodbye") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentMessageIdReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-2" };
Assert.False(a.ContentEquals(b));
}
[Fact]
public void OnlyLeftHasMessageIdFallsThroughToContentComparison()
{
ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
ChatMessage b = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(b));
}
[Fact]
public void OnlyRightHasMessageIdFallsThroughToContentComparison()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
Assert.True(a.ContentEquals(b));
}
#endregion
#region Role and AuthorName
[Fact]
public void DifferentRoleReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.Assistant, "Hello");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentAuthorNameReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello") { AuthorName = "Alice" };
ChatMessage b = new(ChatRole.User, "Hello") { AuthorName = "Bob" };
Assert.False(a.ContentEquals(b));
}
[Fact]
public void BothNullAuthorNamesAreEqual()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Hello");
Assert.True(a.ContentEquals(b));
}
#endregion
#region TextContent
[Fact]
public void EqualTextContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, "Hello world");
ChatMessage b = new(ChatRole.User, "Hello world");
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentTextContentReturnsFalse()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "Goodbye");
Assert.False(a.ContentEquals(b));
}
[Fact]
public void TextContentIsCaseSensitive()
{
ChatMessage a = new(ChatRole.User, "Hello");
ChatMessage b = new(ChatRole.User, "hello");
Assert.False(a.ContentEquals(b));
}
#endregion
#region TextReasoningContent
[Fact]
public void EqualTextReasoningContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentReasoningTextReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("alpha")]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("beta")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentProtectedDataReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "x" }]);
ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "y" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region DataContent
[Fact]
public void EqualDataContentReturnsTrue()
{
byte[] data = Encoding.UTF8.GetBytes("payload");
ChatMessage a = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentDataBytesReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("aaa"), "text/plain")]);
ChatMessage b = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("bbb"), "text/plain")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentMediaTypeReturnsFalse()
{
byte[] data = [1, 2, 3];
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png")]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/jpeg")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentDataContentNameReturnsFalse()
{
byte[] data = [1, 2, 3];
ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "a.png" }]);
ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "b.png" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region UriContent
[Fact]
public void EqualUriContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentUriReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://a.com/x"), "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://b.com/x"), "image/png")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentUriMediaTypeReturnsFalse()
{
Uri uri = new("https://example.com/file");
ChatMessage a = new(ChatRole.User, [new UriContent(uri, "image/png")]);
ChatMessage b = new(ChatRole.User, [new UriContent(uri, "image/jpeg")]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region ErrorContent
[Fact]
public void EqualErrorContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentErrorMessageReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail")]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("crash")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentErrorCodeReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E002" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region FunctionCallContent
[Fact]
public void EqualFunctionCallContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["city"] = "Seattle" } }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentCallIdReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-2", "get_weather")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentFunctionNameReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_time")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentArgumentsReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "2" } }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void NullArgumentsBothSidesReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void OneNullArgumentsReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentArgumentCountReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1", ["y"] = "2" } }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region FunctionResultContent
[Fact]
public void EqualFunctionResultContentReturnsTrue()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentResultCallIdReturnsFalse()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-2", "sunny")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentResultValueReturnsFalse()
{
ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "rainy")]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region HostedFileContent
[Fact]
public void EqualHostedFileContentReturnsTrue()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentFileIdReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc")]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-xyz")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentHostedFileMediaTypeReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/plain" }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentHostedFileNameReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "a.csv" }]);
ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "b.csv" }]);
Assert.False(a.ContentEquals(b));
}
#endregion
#region Content list structure
[Fact]
public void DifferentContentCountReturnsFalse()
{
ChatMessage a = new(ChatRole.User, [new TextContent("one"), new TextContent("two")]);
ChatMessage b = new(ChatRole.User, [new TextContent("one")]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void MixedContentTypesInSameOrderReturnsTrue()
{
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
Assert.True(a.ContentEquals(b));
}
[Fact]
public void MismatchedContentTypeOrderReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("c1", "fn"), new TextContent("reply") });
Assert.False(a.ContentEquals(b));
}
[Fact]
public void EmptyContentsListsAreEqual()
{
ChatMessage a = new() { Role = ChatRole.User, Contents = [] };
ChatMessage b = new() { Role = ChatRole.User, Contents = [] };
Assert.True(a.ContentEquals(b));
}
[Fact]
public void SameContentItemReferenceReturnsTrue()
{
// Exercises the ReferenceEquals fast-path on individual AIContent items.
TextContent shared = new("Hello");
ChatMessage a = new(ChatRole.User, [shared]);
ChatMessage b = new(ChatRole.User, [shared]);
Assert.True(a.ContentEquals(b));
}
#endregion
#region Unknown AIContent subtype
[Fact]
public void UnknownContentSubtypeSameTypeReturnsTrue()
{
// Unknown subtypes with the same concrete type are considered equal.
ChatMessage a = new(ChatRole.User, [new StubContent()]);
ChatMessage b = new(ChatRole.User, [new StubContent()]);
Assert.True(a.ContentEquals(b));
}
[Fact]
public void DifferentUnknownContentSubtypesReturnFalse()
{
ChatMessage a = new(ChatRole.User, [new StubContent()]);
ChatMessage b = new(ChatRole.User, [new OtherStubContent()]);
Assert.False(a.ContentEquals(b));
}
private sealed class StubContent : AIContent;
private sealed class OtherStubContent : AIContent;
#endregion
}
@@ -0,0 +1,255 @@
// 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="ChatReducerCompactionStrategy"/> class.
/// </summary>
public class ChatReducerCompactionStrategyTests
{
[Fact]
public void ConstructorNullReducerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatReducerCompactionStrategy(null!, CompactionTriggers.Always));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger never fires
TestChatReducer reducer = new(messages => messages.Take(1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Never);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, reducer.CallCount);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncReducerReturnsFewerMessagesRebuildsIndexAsync()
{
// Arrange — reducer keeps only the last message
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(1, reducer.CallCount);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal("Second", index.Groups[0].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncReducerReturnsSameCountReturnsFalseAsync()
{
// Arrange — reducer returns all messages (no reduction)
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(1, reducer.CallCount);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncEmptyIndexReturnsFalseAsync()
{
// Arrange — no included messages
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create([]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, reducer.CallCount);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync()
{
// Arrange — reducer keeps system + last user message
TestChatReducer reducer = new(messages =>
{
var nonSystem = messages.Where(m => m.Role != ChatRole.System).ToList();
return messages.Where(m => m.Role == ChatRole.System)
.Concat(nonSystem.Skip(nonSystem.Count - 1));
});
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(CompactionGroupKind.System, index.Groups[0].Kind);
Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text);
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
Assert.Equal("Second", index.Groups[1].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync()
{
// Arrange — reducer keeps last 3 messages (assistant tool call + tool result + user)
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 3));
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old question"),
new ChatMessage(ChatRole.Assistant, "Old answer"),
assistantToolCall,
toolResult,
new ChatMessage(ChatRole.User, "New question"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
// Should have 2 groups: ToolCall group (assistant + tool result) + User group
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind);
Assert.Equal(2, index.Groups[0].Messages.Count);
Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind);
}
[Fact]
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
{
// Arrange — one group is pre-excluded, reducer keeps last message
TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Excluded"),
new ChatMessage(ChatRole.User, "Included 1"),
new ChatMessage(ChatRole.User, "Included 2"),
]);
index.Groups[0].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — reducer only saw 2 included messages, kept 1
Assert.True(result);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal("Included 2", index.Groups[0].Messages[0].Text);
}
[Fact]
public async Task CompactAsyncExposesReducerPropertyAsync()
{
// Arrange
TestChatReducer reducer = new(messages => messages);
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
// Assert
Assert.Same(reducer, strategy.ChatReducer);
await Task.CompletedTask;
}
[Fact]
public async Task CompactAsyncPassesCancellationTokenToReducerAsync()
{
// Arrange
using CancellationTokenSource cancellationSource = new();
CancellationToken capturedToken = default;
TestChatReducer reducer = new((messages, cancellationToken) =>
{
capturedToken = cancellationToken;
return Task.FromResult<IEnumerable<ChatMessage>>(messages.Skip(messages.Count() - 1).ToList());
});
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
await strategy.CompactAsync(index, logger: null, cancellationSource.Token);
// Assert
Assert.Equal(cancellationSource.Token, capturedToken);
}
/// <summary>
/// A test implementation of <see cref="IChatReducer"/> that applies a configurable reduction function.
/// </summary>
private sealed class TestChatReducer : IChatReducer
{
private readonly Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> _reduceFunc;
public TestChatReducer(Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> reduceFunc)
{
this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages));
}
public TestChatReducer(Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> reduceFunc)
{
this._reduceFunc = reduceFunc;
}
public int CallCount { get; private set; }
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this.CallCount++;
return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,366 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="CompactionProvider"/> class.
/// </summary>
public sealed class CompactionProviderTests
{
[Fact]
public void ConstructorThrowsOnNullStrategy()
{
Assert.Throws<ArgumentNullException>(() => new CompactionProvider(null!));
}
[Fact]
public void StateKeysReturnsExpectedKey()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
// Act & Assert — default state key is the strategy type name
Assert.Single(provider.StateKeys);
Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]);
}
[Fact]
public void StateKeysAreStableAcrossEquivalentInstances()
{
// Arrange — two providers with equivalent (but distinct) strategies
CompactionProvider provider1 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
CompactionProvider provider2 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
// Act & Assert — default keys must be identical for session state stability
Assert.Equal(provider1.StateKeys[0], provider2.StateKeys[0]);
}
[Fact]
public void StateKeysReturnsCustomKeyWhenProvided()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy, stateKey: "my-custom-key");
// Act & Assert
Assert.Single(provider.StateKeys);
Assert.Equal("my-custom-key", provider.StateKeys[0]);
}
[Fact]
public async Task InvokingAsyncNoSessionPassesThroughAsync()
{
// Arrange — no session → passthrough
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session: null,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original context returned unchanged
Assert.Same(messages, result.Messages);
}
[Fact]
public async Task InvokingAsyncNullMessagesPassesThroughAsync()
{
// Arrange — messages is null → passthrough
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = null });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original context returned unchanged
Assert.Null(result.Messages);
}
[Fact]
public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync()
{
// Arrange — strategy that always triggers and keeps only 1 group
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — compaction should have reduced the message count
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.True(resultList.Count < messages.Count);
}
[Fact]
public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
{
// Arrange — trigger never fires → no compaction
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — original messages passed through
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public async Task InvokingAsyncPreservesInstructionsAndToolsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
AITool[] tools = [AIFunctionFactory.Create(() => "tool", "MyTool")];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext
{
Instructions = "Be helpful",
Messages = messages,
Tools = tools
});
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert — instructions and tools are preserved
Assert.Equal("Be helpful", result.Instructions);
Assert.Same(tools, result.Tools);
}
[Fact]
public async Task InvokingAsyncWithExistingIndexUpdatesAsync()
{
// Arrange — call twice to exercise the "existing index" path
TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> messages1 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
AIContextProvider.InvokingContext context1 = new(
mockAgent.Object,
session,
new AIContext { Messages = messages1 });
// First call — initializes state
await provider.InvokingAsync(context1);
List<ChatMessage> messages2 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
];
AIContextProvider.InvokingContext context2 = new(
mockAgent.Object,
session,
new AIContext { Messages = messages2 });
// Act — second call exercises the update path
AIContext result = await provider.InvokingAsync(context2);
// Assert
Assert.NotNull(result.Messages);
}
[Fact]
public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync()
{
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// Use an IEnumerable (not a List) to trigger the copy path
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
AIContextProvider.InvokingContext context = new(
mockAgent.Object,
session,
new AIContext { Messages = messages });
// Act
AIContext result = await provider.InvokingAsync(context);
// Assert
Assert.NotNull(result.Messages);
List<ChatMessage> resultList = [.. result.Messages!];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public async Task CompactAsyncThrowsOnNullStrategyAsync()
{
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
await Assert.ThrowsAsync<ArgumentNullException>(() => CompactionProvider.CompactAsync(null!, messages));
}
[Fact]
public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync()
{
// Arrange — trigger never fires → no compaction
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — all messages preserved
List<ChatMessage> resultList = [.. result];
Assert.Equal(messages.Count, resultList.Count);
Assert.Equal("Q1", resultList[0].Text);
Assert.Equal("A1", resultList[1].Text);
Assert.Equal("Q2", resultList[2].Text);
}
[Fact]
public async Task CompactAsyncReducesMessagesWhenTriggeredAsync()
{
// Arrange — strategy that always triggers and keeps only 1 group
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — compaction should have reduced the message count
List<ChatMessage> resultList = [.. result];
Assert.True(resultList.Count < messages.Count);
}
[Fact]
public async Task CompactAsyncHandlesEmptyMessageListAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
List<ChatMessage> messages = [];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task CompactAsyncWorksWithNonListEnumerableAsync()
{
// Arrange — IEnumerable (not a List<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
List<ChatMessage> resultList = [.. result];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public void CompactionStateAssignment()
{
// Arrange
CompactionProvider.State state = new();
// Assert
Assert.NotNull(state.MessageGroups);
Assert.Empty(state.MessageGroups);
// Act
state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
// Assert
Assert.Single(state.MessageGroups);
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,236 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
/// </summary>
public class CompactionStrategyTests
{
[Fact]
public void ConstructorNullTriggerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger never fires, but enough non-system groups to pass short-circuit
TestStrategy strategy = new(_ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncTriggerMetCallsApplyAsync()
{
// Arrange — trigger always fires, enough non-system groups
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
{
// Arrange — trigger fires but Apply does nothing
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync()
{
// Arrange — trigger would fire, but only 1 non-system group → short-circuit
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — short-circuited before trigger or Apply
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync()
{
// Arrange — system group + 1 non-system group → still short-circuits
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Hello"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system groups don't count, still only 1 non-system group
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync()
{
// Arrange — exactly 2 non-system groups: boundary passes, trigger fires
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — not short-circuited, Apply was called
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
{
// Arrange — trigger fires when groups > 2
// Default target should be: stop when groups <= 2 (i.e., !trigger)
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
TestStrategy strategy = new(trigger, applyFunc: index =>
{
// Exclude oldest non-system group one at a time
foreach (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
group.IsExcluded = true;
// Target (default = !trigger) returns true when groups <= 2
// So the strategy would check Target after this exclusion
break;
}
}
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — trigger fires (4 > 2), Apply is called
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
{
// Arrange — custom target that always signals stop
bool targetCalled = false;
bool CustomTarget(CompactionMessageIndex _)
{
targetCalled = true;
return true;
}
TestStrategy strategy = new(_ => true, CustomTarget, _ =>
{
// Access the target from within the strategy
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom target is accessible (verified by TestStrategy checking it)
Assert.Equal(1, strategy.ApplyCallCount);
// The target is accessible to derived classes via the protected property
Assert.True(strategy.InvokeTarget(index));
Assert.True(targetCalled);
}
/// <summary>
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
/// </summary>
private sealed class TestStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool>? _applyFunc;
public TestStrategy(
CompactionTrigger trigger,
CompactionTrigger? target = null,
Func<CompactionMessageIndex, bool>? applyFunc = null)
: base(trigger, target)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
/// <summary>
/// Exposes the protected Target property for test verification.
/// </summary>
public bool InvokeTarget(CompactionMessageIndex index) => this.Target(index);
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
bool result = this._applyFunc?.Invoke(index) ?? false;
return new(result);
}
}
}
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
/// </summary>
public class CompactionTriggersTests
{
[Fact]
public void TokensExceedReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceedReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
CompactionMessageIndex small = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
CompactionMessageIndex large = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.False(trigger(small));
Assert.True(trigger(large));
}
[Fact]
public void TurnsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
CompactionMessageIndex oneTurn = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
CompactionMessageIndex twoTurns = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
Assert.False(trigger(oneTurn));
Assert.True(trigger(twoTurns));
}
[Fact]
public void GroupsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
Assert.False(trigger(index));
}
[Fact]
public void AllRequiresAllConditions()
{
CompactionTrigger trigger = CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.MessagesExceed(5));
CompactionMessageIndex small = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens > 0 is true, but messages > 5 is false
Assert.False(trigger(small));
}
[Fact]
public void AnyRequiresAtLeastOneCondition()
{
CompactionTrigger trigger = CompactionTriggers.Any(
CompactionTriggers.TokensExceed(999_999),
CompactionTriggers.MessagesExceed(0));
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens not exceeded, but messages > 0 is true
Assert.True(trigger(index));
}
[Fact]
public void AllEmptyTriggersReturnsTrue()
{
CompactionTrigger trigger = CompactionTriggers.All();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void AnyEmptyTriggersReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
[Fact]
public void TokensBelowReturnsTrueWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.True(trigger(index));
}
[Fact]
public void TokensBelowReturnsFalseWhenAboveThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
Assert.False(trigger(index));
}
[Fact]
public void AlwaysReturnsTrue()
{
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(CompactionTriggers.Always(index));
}
}
@@ -0,0 +1,208 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
{
// Arrange
List<string> executionOrder = [];
TestCompactionStrategy strategy1 = new(
_ =>
{
executionOrder.Add("first");
return false;
});
TestCompactionStrategy strategy2 = new(
_ =>
{
executionOrder.Add("second");
return false;
});
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
TestCompactionStrategy strategy2 = new(_ => true);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => true);
TestCompactionStrategy strategy2 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
Assert.Equal(1, strategy1.ApplyCallCount);
Assert.Equal(1, strategy2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
static void ExcludeOldest2(CompactionMessageIndex index)
{
int excluded = 0;
foreach (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
}
TestCompactionStrategy phase1 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
TestCompactionStrategy phase2 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
PipelineCompactionStrategy pipeline = new(phase1, phase2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
Assert.Equal(1, phase1.ApplyCallCount);
Assert.Equal(1, phase2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
/// <summary>
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
/// </summary>
private sealed class TestCompactionStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<CompactionMessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return new(this._applyFunc(index));
}
}
}
@@ -0,0 +1,311 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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="SlidingWindowCompactionStrategy"/> class.
/// </summary>
public class SlidingWindowCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
{
// Arrange — trigger requires > 3 turns, conversation has 2
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
{
// Arrange — trigger on > 2 turns, conversation has 3
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 (Q1 + A1) should be excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 and 3 should remain
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
Assert.False(groups.Groups[4].IsExcluded);
Assert.False(groups.Groups[5].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System preserved
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 kept (user + tool call group)
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 99 turns
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(3, included.Count);
Assert.Equal("System", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
{
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
int removeCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++removeCount >= 1;
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
new ChatMessage(ChatRole.User, "Q4"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only turn 1 excluded (target stopped after 1 removal)
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
}
[Fact]
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
{
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 2,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — target never says stop, but MinimumPreserved=2 protects the last 2 turns
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
// Turn 1 excluded
Assert.True(index.Groups[0].IsExcluded); // Q1
Assert.True(index.Groups[1].IsExcluded); // A1
// Last 2 turns must be preserved
Assert.False(index.Groups[2].IsExcluded); // Q2
Assert.False(index.Groups[3].IsExcluded); // A2
Assert.False(index.Groups[4].IsExcluded); // Q3
Assert.False(index.Groups[5].IsExcluded); // A3
}
[Fact]
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
{
// Arrange — includes system and pre-excluded groups that must be skipped
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
index.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system preserved, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System preserved
}
[Fact]
public async Task CompactAsyncPreservesTurnIndexZeroAsync()
{
// Arrange — assistant message before first user turn gets TurnIndex = 0
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.Assistant, "Welcome!"), // TurnIndex = 0
new ChatMessage(ChatRole.User, "Q1"), // TurnIndex = 1
new ChatMessage(ChatRole.Assistant, "A1"), // TurnIndex = 1
new ChatMessage(ChatRole.User, "Q2"), // TurnIndex = 2
new ChatMessage(ChatRole.Assistant, "A2"), // TurnIndex = 2
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — TurnIndex = 0 is always preserved even with minimumPreservedTurns = 0
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // Welcome (TurnIndex 0) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 (TurnIndex 1) excluded
Assert.True(index.Groups[2].IsExcluded); // A1 (TurnIndex 1) excluded
Assert.True(index.Groups[3].IsExcluded); // Q2 (TurnIndex 2) excluded
Assert.True(index.Groups[4].IsExcluded); // A2 (TurnIndex 2) excluded
}
[Fact]
public async Task CompactAsyncPreservesNullTurnIndexAsync()
{
// Arrange — system messages (TurnIndex = null) should never be removed
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(0),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system message (TurnIndex null) always preserved
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System (TurnIndex null) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 excluded
Assert.True(index.Groups[2].IsExcluded); // A1 excluded
}
}
@@ -0,0 +1,613 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
public class SummarizationCompactionStrategyTests
{
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
/// </summary>
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
{
Mock<IChatClient> mock = new();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
return mock.Object;
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 100000 tokens
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.TokensExceed(100000),
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncSummarizesOldGroupsAsync()
{
// Arrange — always trigger, preserve 1 recent group
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Key facts from earlier."),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First question"),
new ChatMessage(ChatRole.Assistant, "First answer"),
new ChatMessage(ChatRole.User, "Second question"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. index.GetIncludedMessages()];
// Should have: summary + preserved recent group (Second question)
Assert.Equal(2, included.Count);
Assert.Contains("[Summary]", included[0].Text);
Assert.Contains("Key facts from earlier.", included[0].Text);
Assert.Equal("Second question", included[1].Text);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Old question"),
new ChatMessage(ChatRole.Assistant, "Old answer"),
new ChatMessage(ChatRole.User, "Recent question"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal(ChatRole.System, included[0].Role);
}
[Fact]
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary text."),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — summary should be inserted after system, before preserved group
CompactionMessageGroup summaryGroup = index.Groups.First(g => g.Kind == CompactionGroupKind.Summary);
Assert.NotNull(summaryGroup);
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(CompactionMessageGroup.SummaryPropertyKey));
}
[Fact]
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
{
// Arrange — LLM returns whitespace
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(" "),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — should use fallback text
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Contains("[Summary unavailable]", included[0].Text);
}
[Fact]
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 non-system groups
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 5);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncUsesCustomPromptAsync()
{
// Arrange — capture the messages sent to the chat client
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
capturedMessages = [.. msgs])
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
const string CustomPrompt = "Summarize in bullet points only.";
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1,
summarizationPrompt: CustomPrompt);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom prompt should be the system message, followed by the original messages
Assert.NotNull(capturedMessages);
Assert.Equal(2, capturedMessages.Count);
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
Assert.Equal("Q1", capturedMessages[1].Text);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
CompactionMessageGroup excluded = index.Groups.First(g => g.IsExcluded);
Assert.NotNull(excluded.ExcludeReason);
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
}
[Fact]
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
{
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
int exclusionCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++exclusionCount >= 1;
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Partial summary."),
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — only 1 group should have been summarized (target met after first exclusion)
int excludedCount = index.Groups.Count(g => g.IsExcluded);
Assert.Equal(1, excludedCount);
}
[Fact]
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
{
// Arrange — preserve 2
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary."),
CompactionTriggers.Always,
minimumPreservedGroups: 2);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal(3, included.Count); // summary + Q2 + A2
Assert.Contains("[Summary]", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
{
// Arrange — system group between user/assistant groups to exercise skip logic in loop
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.System, "System note"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — summary inserted at 0, system group shifted to index 2
Assert.True(result);
Assert.Equal(CompactionGroupKind.Summary, index.Groups[0].Kind);
Assert.Equal(CompactionGroupKind.System, index.Groups[2].Kind);
Assert.False(index.Groups[2].IsExcluded); // System never excluded
}
[Fact]
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
{
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 3,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act — should only summarize 6-3 = 3 groups (not all 6)
bool result = await strategy.CompactAsync(index);
// Assert — 3 preserved + 1 summary = 4 included
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncWithPreExcludedGroupAsync()
{
// Arrange — pre-exclude a group so the count and loop both must skip it
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Still excluded
}
[Fact]
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
{
// Arrange — a message with null text (FunctionCallContent) in a summarized group
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Act — the tool-call group's message has null text
bool result = await strategy.CompactAsync(index);
// Assert — compaction succeeded despite null text
Assert.True(result);
}
#region Error resilience
[Fact]
public async Task CompactAsyncLlmFailureRestoresGroupsAsync()
{
// Arrange — chat client throws a non-cancellation exception
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
int originalGroupCount = index.Groups.Count;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — returns false, all groups restored to non-excluded
Assert.False(result);
Assert.Equal(originalGroupCount, index.Groups.Count);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
}
[Fact]
public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync()
{
// Arrange — verify that after failure, GetIncludedMessages returns all original messages
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Timeout"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
List<ChatMessage> originalIncluded = [.. index.GetIncludedMessages()];
// Act
await strategy.CompactAsync(index);
// Assert — all original messages still included
List<ChatMessage> afterIncluded = [.. index.GetIncludedMessages()];
Assert.Equal(originalIncluded.Count, afterIncluded.Count);
for (int i = 0; i < originalIncluded.Count; i++)
{
Assert.Same(originalIncluded[i], afterIncluded[i]);
}
}
[Fact]
public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync()
{
// Arrange
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("API error"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — no Summary group was inserted
Assert.DoesNotContain(index.Groups, g => g.Kind == CompactionGroupKind.Summary);
}
[Fact]
public async Task CompactAsyncCancellationPropagatesAsync()
{
// Arrange — OperationCanceledException should NOT be caught
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException("Cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — OperationCanceledException propagates
await Assert.ThrowsAsync<OperationCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncTaskCancellationPropagatesAsync()
{
// Arrange — TaskCanceledException (subclass of OperationCanceledException) should also propagate
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("Task cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — TaskCanceledException propagates (inherits from OperationCanceledException)
await Assert.ThrowsAsync<TaskCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsync()
{
// Arrange — multiple groups excluded before failure, all must be restored
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Rate limited"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: _ => false); // Never stop — exclude as many as possible
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — all non-system groups restored
Assert.False(result);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
Assert.Equal(6, index.IncludedGroupCount);
}
#endregion
}
@@ -0,0 +1,351 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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="ToolResultCompactionStrategy"/> class.
/// </summary>
public class ToolResultCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
toolCall,
toolResult,
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
{
// Arrange — always trigger
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F", included[1].Text);
Assert.Equal("Q2", included[2].Text);
}
[Fact]
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
{
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 3);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — all groups are in the protected window, nothing to collapse
Assert.False(result);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
}
[Fact]
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
{
// Arrange — assistant calls two tools
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
multiToolCall,
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\nsearch_docs:\n - Found 3 docs", collapsed);
}
[Fact]
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 0);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
{
// Arrange — compound: tokens > 0 AND has tool calls
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.HasToolCalls()),
minimumPreservedGroups: 1);
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
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
{
// Arrange — 2 tool groups, target met after first collapse
int collapseCount = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++collapseCount >= 1;
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
new ChatMessage(ChatRole.Tool, "result1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
new ChatMessage(ChatRole.Tool, "result2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only first tool group collapsed, second left intact
Assert.True(result);
// Count collapsed tool groups (excluded with ToolCall kind)
int collapsedToolGroups = 0;
foreach (CompactionMessageGroup group in index.Groups)
{
if (group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall)
{
collapsedToolGroups++;
}
}
Assert.Equal(1, collapsedToolGroups);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — pre-excluded and system groups in the enumeration
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 0);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q0"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "Result 1"),
new ChatMessage(ChatRole.User, "Q1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Pre-exclude the last user group
index.Groups[index.Groups.Count - 1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system never excluded, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System stays
}
[Fact]
public async Task CompactAsyncDeduplicatesDuplicateToolNamesAsync()
{
// Arrange — same tool called multiple times
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Rainy"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate names listed once with all results
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy", included[1].Text);
}
[Fact]
public async Task CompactAsyncIncludesResultsFromFunctionResultContentAsync()
{
// Arrange — tool results provided as FunctionResultContent (matched by CallId)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — results matched by CallId and included in summary
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync()
{
// Arrange — same tool called multiple times with FunctionResultContent
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
new FunctionCallContent("c3", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Rainy")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c3", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate tool name results listed under same key
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
}
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
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="TruncationCompactionStrategy"/> class.
/// </summary>
public class TruncationCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
{
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens, conversation is tiny
TruncationCompactionStrategy strategy = new(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.TokensExceed(1000));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
{
// Arrange — trigger on groups > 2
TruncationCompactionStrategy strategy = new(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.GroupsExceed(2));
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
// Oldest 2 excluded, newest 2 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be preserved
Assert.False(groups.Groups[0].IsExcluded);
Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind);
// Oldest non-system groups excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent kept
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
CompactionMessageIndex groups = CompactionMessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Tool call group should be excluded as one atomic unit
Assert.True(groups.Groups[0].IsExcluded);
Assert.Equal(CompactionGroupKind.ToolCall, groups.Groups[0].Kind);
Assert.Equal(2, groups.Groups[0].Messages.Count);
Assert.False(groups.Groups[1].IsExcluded);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
Assert.NotNull(groups.Groups[0].ExcludeReason);
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
}
[Fact]
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
new ChatMessage(ChatRole.User, "Included 1"),
new ChatMessage(ChatRole.User, "Included 2"),
]);
groups.Groups[0].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
Assert.False(groups.Groups[2].IsExcluded); // kept
}
[Fact]
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
{
// Arrange — keep 2 most recent
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 groups
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 5);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
{
// Arrange — always trigger, custom target stops after 1 exclusion
int targetChecks = 0;
bool TargetAfterOne(CompactionMessageIndex _) => ++targetChecks >= 1;
TruncationCompactionStrategy strategy = new(
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 1 group excluded (target met after first)
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.False(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
{
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
TruncationCompactionStrategy strategy = new(
CompactionTriggers.GroupsExceed(2),
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
bool result = await strategy.CompactAsync(groups);
// Assert — should stop at 2 included groups (not go all the way to 1)
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
{
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2, target: CompactionTriggers.Never);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — has excluded + system groups that the loop must skip
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
groups.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
}
}
@@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
@@ -3,9 +3,12 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests;
@@ -342,4 +345,148 @@ public sealed class DefaultMcpToolHandlerTests
}
#endregion
#region ConvertContentBlock Tests
[Fact]
public void ConvertContentBlock_TextContentBlock_ShouldReturnTextContent()
{
// Arrange
TextContentBlock block = new() { Text = "hello world" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("hello world");
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri()
{
// Arrange
ImageContentBlock block = new() { Data = ReadOnlyMemory<byte>.Empty, MimeType = "image/png" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Uri.Should().Be("data:image/png;base64,");
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "image/png" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Uri.Should().Be("data:image/png;base64,iVBORw0KGgo=");
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:image/jpeg;base64,/9j/4AAQ";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "image/jpeg" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/jpeg");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithNullMimeType_ShouldDefaultToImageWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/*");
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri()
{
// Arrange
AudioContentBlock block = new() { Data = ReadOnlyMemory<byte>.Empty, MimeType = "audio/wav" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Uri.Should().Be("data:audio/wav;base64,");
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "audio/wav" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Uri.Should().Be("data:audio/wav;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:audio/mp3;base64,//uQxAAA";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "audio/mp3" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/mp3");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefaultToAudioWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/*");
}
#endregion
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

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