Compare commits

..
Author SHA1 Message Date
47fa59f8e9 Python: bump package versions for 1.4.0 release (#5872)
* fixes

* fixes

* Python: bump package versions for 1.4.0 release

Cuts the python-1.4.0 release. MINOR bump on the released cohort
(agent-framework, agent-framework-core, agent-framework-openai,
agent-framework-foundry: 1.3.0 -> 1.4.0), driven by breaking changes
in experimental skills API and new features. All 21 beta packages
stamp 1.0.0b260514, all 3 alpha packages stamp 1.0.0a260514, and
ag-ui remains at 1.0.0rc1 (freshly promoted). Date stamp reflects
2026-05-14 Pacific.

- Released cohort: 1.3.0 -> 1.4.0
- Beta packages (21): 1.0.0b260507 -> 1.0.0b260514
- Alpha packages (3): 1.0.0a260507 -> 1.0.0a260514
- ag-ui: stays at 1.0.0rc1 (dep bound updated only)
- Inter-package dependency lower bounds updated (>=1.3.0 -> >=1.4.0)
- Fix chatkit StructuredInputItem exhaustiveness for openai-chatkit 1.6.4
- Update CHANGELOG compare links
- uv.lock refreshed

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 09:31:03 +09:00
68357b0250 Python: Fix A2A v1.0 non-streaming response and sample runtime issues (#5849)
- Fix non-streaming empty response by accumulating intermediate WORKING
  status updates and flushing them when an empty terminal event arrives
- Fix sample agent_executor.py to enqueue Task before status events
  (required by v1.0 ActiveTask validation)
- Fix create_jsonrpc_routes() calls to include required rpc_url param
- Fix TYPE_CHECKING imports in sample agent_definitions.py
- Add tests for non-streaming content accumulation behavior

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

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

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

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

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

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

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

* Clarify MSRC repro padding length in regression test

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

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

* add E2E http test for checkpoint context id rejection

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

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

---------

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

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

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

* Add MagenticOrchestrationTests.cs scaffold for Magentic E2E tests

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

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

* Fix MagenticOrchestrator output declaration and add first E2E test

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

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

* Add plan review test and event emission tests

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

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

* Add next speaker validation test

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

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

* Add Magentic E2E implementation review

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

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

* Add PlanSignoff_Disabled_Proceeds_Immediately E2E test

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

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

* Add NextSpeaker_Empty_Falls_Back_To_First E2E test

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

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

* Add Task_Completes_After_Multiple_Rounds E2E test

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

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

* Add PlanReview_Revised_Triggers_Replan E2E test

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

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

* Add MaxRoundLimit_Terminates_Workflow E2E test

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

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

* Add MaxStallCount_Triggers_Reset E2E test

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

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

* Update MagenticE2E_ImplementationReview.md with full coverage status

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

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

* Rewrite Magentic E2E implementation review

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

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

* Add MaxResetLimit_Terminates_Workflow E2E test

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

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

* Add PlanReview_On_Stall_Replan E2E test

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

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

* Add Instruction_Message_Sent_When_Present E2E test

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

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

* Update ImplementationReview.md to reflect 14 tests

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

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

* Rewrite Magentic E2E implementation review

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

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

* Add ProgressLedger_Retry_On_Parse_Failure E2E test

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

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

* Add ProgressLedger_Max_Retries_Triggers_Reset E2E test

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

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

* Add Stall_NoProgress_Increments_StallCount E2E test

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

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

* Add PlanReview_Multiple_Revisions E2E test

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

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

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

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

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

* Rewrite Magentic E2E implementation review

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

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

* Preserve IsStalled on stall-triggered plan review requests

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

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

* Rename isStalled parameter to replanAfterStall for clarity

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

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

* Add Task_Delegates_To_Correct_Agent E2E test with multi-participant routing assertion

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

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

* Add Progress_Made_Decrements_StallCount E2E test verifying stall count decrement avoids reset

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

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

* Add Consecutive_Stalls_Trigger_Reset E2E test for multi-stall threshold reset

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

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

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

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

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

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

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

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

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

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

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

* Rewrite Magentic E2E implementation review

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

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

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

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

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

* Rewrite Magentic implementation review

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

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

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

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

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

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

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

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

* Rewrite ImplementationReview.md with final 23-test status

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

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

* Add PR description markdown

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

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

* Remove temporary markdown files

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

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

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

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

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

* Update error messages per review comments in MagenticOrchestrator and MagenticWorkflowBuilder

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

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

* Escape JSON string values in CreateProgressLedgerResponse test helper

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

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

---------

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

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

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

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

* Only set name/description if not NullOrWhitespace

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

---------

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

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

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

* Strengthen workflow edge helper tests

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

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

* Normalize edge helper bad input validation

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

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

* Clarify edge helper target validation

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

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

* Use explicit target parameter names

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

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

* Document workflow edge test helpers

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

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

* Clarify null element validation messages

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

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

* Add repeated chain executor coverage

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

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

* Preserve Throw helper validation style

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

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

* Cover empty switch case targets

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

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

* Relax builder null assertion parameter checks

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

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

* Inline ValidateTargets into call sites

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

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

* Refactor ForwardExcept with TFM-specialized TryGetNonEnumeratedCount

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

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

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

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

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

* Apply TFM-specialized count check to ForwardMessage as well

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

* Addressed initial PR comments.
2026-05-14 15:30:48 +00:00
westeyandGitHub 3047ad3066 .NET: Harness console refactoring (#5811)
* Restructure harness console so that reactive app is the entry point

* Further refactoring to split tool formatters, improve UX, make console configurable and fix bugs

* Address PR comments.

* UX tweak

* Fix streaming text bug

* Address PR comments.
2026-05-14 15:22:11 +00:00
Evan MattsonandGitHub 0e12640c70 Improvements for DevUI (#5840) 2026-05-14 15:05:27 +00:00
Evan MattsonandGitHub ae666a4887 Python: Bump agent-framework-ag-ui to release candidate stage (#5844)
* Bump agent-framework-ag-ui to release candidate stage

* Mark agent-framework-ag-ui as rc in PACKAGE_STATUS
2026-05-14 14:56:34 +00:00
138 changed files with 5856 additions and 4336 deletions
-499
View File
@@ -1,499 +0,0 @@
# PR #5696 — Gap Analysis vs. the MSRC Session-Scoping Reports
This note evaluates how completely the changes in
[PR #5696 — ".NET: Support ClaimsIdentity-based scoping of agent sessions"](https://github.com/microsoft/agent-framework/pull/5696)
address two related MSRC reports against the .NET hosting layer:
- **Part A** — the *A2A persistence-default* report (the originally-filed
variant): an A2A server, configured per the in-tree quickstart, persists
sessions keyed only by the wire-supplied `contextId`, with no
principal/owner dimension on the `AgentSessionStore` contract.
- **Part B** — the *AG-UI `ThreadId` session-hijack* report: the AG-UI
hosting endpoint trusts the client-supplied `RunAgentInput.ThreadId` as
the sole session-lookup key.
Both reports are rated **Low / Defense-in-Depth** by MSRC because the
default registered store is a no-op (`NoopAgentSessionStore`); the
cross-user takeover only materializes when an integrator (a) registers a
persistent store and (b) does not scope the conversation-id namespace by
principal.
> **Design constraint set by the team (applies to both parts):**
> flipping defaults so that the integrator is *required* to register a
> `SessionIsolationKeyProvider` before persistence works is **explicitly
> out of scope**. First-run / single-user / prototyping scenarios — where
> there is no `ClaimsPrincipal`, no auth scheme, and no isolation
> provider — must continue to work "out of the box". Any auto-isolation
> mechanism must therefore degrade to a transparent no-op when no
> principal context is configured.
---
## Part A — A2A persistence-default report
> Reconstructed from the prior session's notes; the verbatim text was not
> committed to the repo. Findings re-verified against PR #5696 head.
### The reported issue
The A2A hosting layer's quickstart pattern —
```csharp
services.AddAIAgent(...)
.WithSessionStore(new InMemoryAgentSessionStore())
.AsA2AServer();
```
— produces a server in which any caller who knows (or guesses) another
caller's `contextId` can resume that other caller's persisted thread,
because:
1. `AgentSessionStore.GetSessionAsync(AIAgent agent, string conversationId, CancellationToken)`
is the only lookup primitive; the contract carries no principal
dimension.
2. `A2AAgentHandler` resolves the keyed `AgentSessionStore` from DI and
passes it directly to `AIHostAgent`, with the wire `contextId` flowing
through unchanged.
3. The persistent stores in-tree (`InMemoryAgentSessionStore`,
sample-grade SQLite/Redis demos) treat `(agent, conversationId)` as a
complete primary key.
Because the documented A2A quickstart leads developers straight at this
shape, any multi-user A2A deployment that copies the quickstart inherits
the cross-tenant takeover.
### How PR #5696 changes the A2A path
The PR introduces an isolation-key abstraction and **auto-wraps on the
A2A code path** when an integrator opts in:
- `SessionIsolationKeyProvider` returns an opaque key from ambient
context (the shipped impl reads `HttpContext.User`).
- `IsolationKeyScopedAgentSessionStore` is a `DelegatingAgentSessionStore`
that prefixes the inbound `conversationId` with `{escapedKey}::` before
forwarding to the inner store. It carries a `Strict` knob:
- `Strict = false` — when the provider returns no key (e.g. anonymous
request, no auth scheme configured), the call falls through with the
bare `conversationId`. This is the "out-of-box" mode.
- `Strict = true` — a missing key throws / refuses the operation.
- `A2AServerServiceCollectionExtensions.CreateA2AServer` resolves the
keyed store, checks the `GetService<IsolationKeyScopedAgentSessionStore>()`
service-locator chain, and wraps if needed with
`Strict = isolationKeyProvider != null`.
Net effect on A2A:
| Scenario | Before PR | After PR |
|---|---|---|
| No persistent store registered | Ephemeral (`NoopAgentSessionStore`) | Unchanged — still ephemeral |
| Persistent store, **no** `UseClaimsBasedSessionIsolation` | Single-namespace persistence (vulnerable in multi-user) | **Same single-namespace persistence** — wrapper installed but in `Strict = false` no-op pass-through, so the out-of-box first-run scenario is preserved |
| Persistent store **plus** `services.UseClaimsBasedSessionIsolation(...)` | Still vulnerable (no wiring existed) | Auto-scoped per principal claim; missing claim → strict refusal |
### Item-by-item map of the A2A report's recommended fixes
| Recommended fix | Status |
|---|---|
| Add a principal/owner dimension to the persistence contract so multi-user safety is *expressible* without per-store rewrites | ✅ Achieved via decorator (`IsolationKeyScopedAgentSessionStore`) rather than a 3-arg `AgentSessionStore` virtual |
| Make the safe path **engage automatically** on the canonical A2A registration shape when an isolation provider is configured | ✅ `CreateA2AServer` auto-wraps the resolved keyed store |
| Preserve out-of-box single-user behaviour (no auth → still works) | ✅ `Strict = false` when no provider is registered; no behavioural change to the no-provider scenario |
| Document the trust model on `AgentSessionStore` / `InMemoryAgentSessionStore` / the A2A quickstart so integrators understand what `(agent, conversationId)` *isn't* | ❌ Not addressed; XML on the legacy surfaces is unchanged |
| Update the A2A end-to-end sample to model the safe pattern with a working auth scheme | ❌ Sample unchanged |
### Residual A2A gaps
- **A-G1.** Trust-model XML still missing on `AgentSessionStore`,
`InMemoryAgentSessionStore`, and the A2A registration entry points.
An integrator reading IntelliSense on the canonical pattern is not
prompted toward `UseClaimsBasedSessionIsolation`.
- **A-G2.** No A2A sample exercises the safe configuration end-to-end.
- **A-G3.** Same `conversationId`-mutation contract caveats as Part B
(B-G3 below): inner stores see a rewritten id; logging/telemetry sinks
will leak the isolation key; stores with id-shape constraints must
accept escaped keys of arbitrary length.
The headline A2A finding (the *handler-level* auto-wrap when a provider
is registered) **is** addressed by this PR, within the agreed
constraint. What remains for A2A is documentation and samples.
---
## Part B — AG-UI `ThreadId` session-hijack report
> The Microsoft Agent Framework AG-UI hosting endpoint (.NET) trusts the
> client-supplied `RunAgentInput.ThreadId` as the sole key for persisted
> session lookup. […] The `AgentSessionStore` abstract contract carries
> only `(AIAgent agent, string conversationId)` with no principal/owner
> parameter, making caller-scoped sessions architecturally impossible
> without a custom implementation.
The MSRC severity rationale is *Low / Defense in Depth* — the default
AG-UI store is `NoopAgentSessionStore`, so the cross-user takeover only
materializes when the integrator (a) registers a persistent store and
(b) does not scope the thread-ID namespace by principal. The framework's
job here is to make the safe path obvious and to document the trust
assumption.
### What the PR delivers (AG-UI-relevant pieces only)
- `SessionIsolationKeyProvider` abstraction
(`dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs`).
- `IsolationKeyScopedAgentSessionStore` decorator that prefixes
`conversationId` with `{escapedKey}::` before forwarding to the inner
store, with a `Strict` knob.
- `DelegatingAgentSessionStore` base + `AgentSessionStore.GetService(...)`
service-locator hooks so wrappers can be discovered in a chain.
- `Microsoft.Agents.AI.Hosting.AspNetCore` package shipping
`ClaimsIdentitySessionIsolationKeyProvider` and
`services.UseClaimsBasedSessionIsolation(...)`.
- Auto-wrap on the **`HostedAgentBuilderExtensions.WithSessionStore(...)`**
path: stores registered through `WithSessionStore(...)` /
`WithInMemorySessionStore(...)` are wrapped in
`IsolationKeyScopedAgentSessionStore` by default
(`withIsolation: true`). When no provider is registered, the wrapper
is constructed with `Strict = false` so it passes the bare
`conversationId` through — i.e. the "out-of-box first-run" constraint
above is honoured.
- AG-UI sample
(`dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs`)
gets a single commented reminder pointing at
`UseClaimsBasedSessionIsolation`.
### Item-by-item map of MSRC's recommended fixes to the PR
#### MSRC Fix #1 — Strengthen XML doc on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` to state the trust model when a persistent store is in play
**Status: ❌ Not addressed on the named surfaces.**
- `AGUIEndpointRouteBuilderExtensions.MapAGUI(...)` doc
(`dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs:70-76`)
still reads:
> If an `AgentSessionStore` is registered in dependency injection
> keyed by the agent's name, it will be used to persist conversation
> sessions across requests using the AG-UI thread ID as the
> conversation identifier. If no session store is registered, sessions
> are ephemeral (not persisted).
No mention of the trust assumption (thread-id = chain-resume token, not
an authorization token; multi-user hosts must scope by principal).
- `InMemoryAgentSessionStore` XML
(`dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs:13-27`)
warns about restart loss but says nothing about the multi-user
takeover risk.
- `AgentSessionStore` base class XML
(`dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs`) only
describes the persistence contract; it does not call out that
`(agent, conversationId)` carries no principal dimension and that
custom implementations must compose one for multi-user hosts.
The new types (`IsolationKeyScopedAgentSessionStore`,
`SessionIsolationKeyProvider`, `ClaimsIdentitySessionIsolationKeyProvider`,
`UseClaimsBasedSessionIsolation`) are themselves well documented, but
nothing on the legacy IntelliSense path prompts the integrator to look
for them.
#### MSRC Fix #2 — Compose principal into the conversation key inside the AG-UI handler when a `ClaimsPrincipal` is available, gated by a versioned opt-in (e.g. `MapAGUIOptions { ScopeSessionsByPrincipal = true }`)
**Status: ⚠️ Partially addressed; opt-in path exists but does not cover
the canonical AG-UI registration shape.**
The PR does *not* edit the AG-UI handler to read `HttpContext.User`. It
takes a different architectural shape: the scoping is done by a
decorator inserted around the store. That decorator must actually be
present in the chain that `MapAGUI` resolves, which means one of these
must be true:
1. The integrator registered the store via the
`IHostedAgentBuilder.WithSessionStore(...)` / `WithInMemorySessionStore(...)`
helper. Those auto-wrap with `IsolationKeyScopedAgentSessionStore`
(the new `withIsolation: true` default).
2. The integrator manually composes
`new IsolationKeyScopedAgentSessionStore(innerStore, provider)` and
registers that as the keyed store.
But the **AG-UI handler resolves the store directly from DI**:
```csharp
// AGUIEndpointRouteBuilderExtensions.cs:85-86
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
```
So an integrator who follows the most natural "I want persistence" line —
```csharp
services.AddKeyedSingleton<AgentSessionStore>(agentName, new InMemoryAgentSessionStore());
// + services.UseClaimsBasedSessionIsolation(...);
```
— still gets **no scoping on AG-UI**, even with an isolation provider
registered, because the keyed store goes straight into `AIHostAgent`
without ever passing through `WithSessionStore`'s wrapping logic. That
is precisely the precondition pattern the MSRC report calls out. (See
gap **B-G1** below.)
There is also no `MapAGUIOptions` / no MapAGUI-level opt-in flag of the
shape MSRC suggested. Discoverability of the safe path lives entirely on
a separate `services.UseClaimsBasedSessionIsolation(...)` extension and
on whether the integrator happened to use the right store-registration
helper.
#### MSRC Fix #3 (optional) — Add a principal-aware overload to `AgentSessionStore` so custom stores can scope by principal without injecting `IHttpContextAccessor`
**Status: ✅ Achieved via decorator instead of a virtual overload.**
The PR does not add a 3-arg `GetSessionAsync(agent, conversationId, scopeId, ct)`
virtual on `AgentSessionStore`. Instead, scope composition is done
upstream by `IsolationKeyScopedAgentSessionStore`, and the inner store
sees a mutated `conversationId` of the form
`{escapedKey}::{conversationId}`. This achieves the *outcome* MSRC asked
for (custom stores no longer need `IHttpContextAccessor`) but with two
documented-by-implication trade-offs noted in **B-G3** below.
### Remaining gaps for the AG-UI threat scenario
#### B-G1. `MapAGUI` does not auto-wrap the keyed store; safe path requires a non-obvious registration shape
This is the headline gap relative to the MSRC AG-UI report. The
isolation wrapper is auto-installed only on the
`HostedAgentBuilder.WithSessionStore(...)` path and on the
A2A `CreateA2AServer` code path. `MapAGUI` reads the keyed
`AgentSessionStore` from DI directly
(`AGUIEndpointRouteBuilderExtensions.cs:85`) and never inspects whether
an `IsolationKeyScopedAgentSessionStore` is in the chain.
Concrete consequence: the canonical "I want persistent AG-UI sessions"
snippet —
```csharp
services.AddKeyedSingleton<AgentSessionStore>("myAgent", new InMemoryAgentSessionStore());
services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
endpoints.MapAGUI("myAgent", "/ag-ui");
```
does *not* scope by principal. The integrator must additionally know to
either (a) register their store via the `WithSessionStore` builder
helper, or (b) hand-compose
`new IsolationKeyScopedAgentSessionStore(inner, provider)` themselves.
**Suggested closure**: in `MapAGUI` (mirroring the A2A pattern), if the
resolved store does not already expose
`IsolationKeyScopedAgentSessionStore` via the new `GetService(...)`
chain, wrap it. Use the same `Strict = isolationKeyProvider != null`
construction the A2A path uses, so the no-provider case stays a
transparent no-op (preserving the out-of-box constraint).
#### B-G2. Trust-model docs missing on the three legacy AG-UI surfaces (MSRC Fix #1)
`MapAGUI`, `InMemoryAgentSessionStore`, and `AgentSessionStore` need the
explicit "thread id is a chain-resume identifier, not an authorization
token; multi-user hosts must scope sessions by principal" wording, with
a pointer to `UseClaimsBasedSessionIsolation` /
`IsolationKeyScopedAgentSessionStore`. Today nothing on those surfaces
flags the risk or points readers at the new safe-path APIs.
#### B-G3. Decorator approach mutates `conversationId` seen by inner stores, undocumented
`IsolationKeyScopedAgentSessionStore` rewrites the `conversationId` to
`{escapedKey}::{conversationId}` before forwarding. Two things follow
that custom-store authors would benefit from being told:
1. Stores that log, echo, or surface `conversationId` (telemetry,
audit, error messages) will leak the isolation key into those sinks.
2. Stores with constraints on conversation-id shape (length, allowed
characters, URL-safety, hashing) must accept `\\`/`\:`-escaped keys
of arbitrary length.
A short note on `IsolationKeyScopedAgentSessionStore` (and ideally on
`AgentSessionStore` itself — "implementations should treat
`conversationId` as opaque") would close this.
#### B-G4. Only one provider implementation ships; `ClaimType` lookup is single-claim only
The MSRC suggested fix uses
`ClaimTypes.NameIdentifier ?? Identity.Name` as the fallback chain.
`ClaimsIdentitySessionIsolationKeyProvider` only resolves a single
configured `ClaimType` and does not fall back to `Identity.Name`. That
will silently produce a `null` key (and, in the non-strict default
configuration on the A2A auto-wrap path, a silent no-op) for valid
authenticated identities that don't carry the configured claim. A
second-chance fallback list, or shipping a small set of common
providers (e.g. `Identity.Name`, an HTTP-header provider, an mTLS
subject provider), would close the discoverability gap on the safe
path.
#### B-G5. AG-UI sample is a commented reminder, not a working safe configuration
`samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs` adds a
single commented-out line. The sample's runtime behaviour is unchanged;
copy-paste users still land on the unscoped path. At least one AG-UI
sample should compile and run with `UseClaimsBasedSessionIsolation`
plus a working auth scheme so the safe pattern is shown end-to-end. The
sample also needs to demonstrate registering the store through
`WithSessionStore(...)` rather than via raw `AddKeyedSingleton` so that
the decorator chain actually engages — unless **B-G1** is fixed first.
#### B-G6. Asymmetry between A2A and AG-UI auto-wrap behaviour
`A2AServerServiceCollectionExtensions.CreateA2AServer` was edited to
auto-wrap any keyed store with `IsolationKeyScopedAgentSessionStore`
when one isn't already in the chain
(`A2AServerServiceCollectionExtensions.cs:140-146` after the PR).
The equivalent change was *not* made on the AG-UI endpoint
extension, so the two hosting layers now have asymmetric behaviour:
isolation kicks in automatically on A2A but only on AG-UI when the
integrator routes their store through the builder helpers. This
re-creates, in a different shape, the "asymmetric defaults are a
perpetual source of integrator confusion" anti-pattern that MSRC
called out for the persistence default. (Note: this is **not** a
"flip the default" ask — it's a "make the two hosting layers behave
the same when an isolation provider is registered" ask. The
out-of-box no-provider behaviour stays unchanged.)
### Summary scorecard (AG-UI threat surface)
| MSRC ask | Status |
|---|---|
| #1 Trust-model XML on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` | ❌ Not addressed |
| #2 Principal-scoping inside the AG-UI handler (versioned opt-in) | ⚠️ Equivalent opt-in mechanism exists (`UseClaimsBasedSessionIsolation` + decorator), but the AG-UI handler does not engage it for stores registered the canonical way |
| #3 Optional principal-aware store overload | ✅ Achieved via decorator (with caveats — see B-G3) |
| AG-UI sample shows safe pattern | ⚠️ Commented reminder only |
| Symmetric behaviour with the A2A hosting layer | ❌ A2A auto-wraps in handler; AG-UI does not |
---
## Part C — Synthesis across the two reports
The A2A and AG-UI reports describe the **same root cause** at the
abstraction layer (`AgentSessionStore` only knows
`(agent, conversationId)`; the wire-supplied id is the entire lookup
key) reached through two different hosting surfaces. Read together they
say:
1. **The contract gap is real, and the PR closes it correctly.** Adding
a principal dimension by *decoration* (`IsolationKeyScopedAgentSessionStore`)
rather than by adding a third parameter to every `AgentSessionStore`
override was the right call: it keeps the contract source-compatible,
it stacks naturally with future scoping dimensions (tenant, session
tag), and it lets a single isolation provider cover every hosting
surface without per-surface plumbing.
2. **"Auto-wrap when the integrator opts in" is the right shape under
the team's design constraint.** The PR does not change defaults: with
no `UseClaimsBasedSessionIsolation` registered, both A2A and the
builder path remain bit-for-bit compatible. The wrapper engages only
when an isolation provider is in the container, and even then defers
to `Strict = false` when the provider returns no key — so the
first-run / single-user / no-auth scenario continues to function.
3. **Coverage is asymmetric across hosting surfaces.** The PR added the
handler-level auto-wrap to the A2A path (`CreateA2AServer`) and to
the agent-builder path (`WithSessionStore`), but **not** to the
AG-UI handler (`MapAGUI`). The result: an integrator who follows
the canonical AG-UI snippet
(`AddKeyedSingleton<AgentSessionStore>(name, store)` +
`endpoints.MapAGUI(name, "/ag-ui")`) plus
`UseClaimsBasedSessionIsolation(...)` still gets an unscoped store.
This is the headline residual issue (**B-G1 / B-G6**) and is also
the reason the AG-UI report exists as a separate finding even after
the A2A path is fixed.
4. **Documentation and samples lag the runtime fix on both surfaces.**
`AgentSessionStore`, `InMemoryAgentSessionStore`, the A2A quickstart,
and the AG-UI quickstart all still describe the persistence contract
without flagging the trust assumption that `(agent, conversationId)`
carries no principal dimension and that multi-user hosts must compose
one. The new types are well-documented, but nothing on the legacy
IntelliSense path points readers at them. A second-time reader of
either MSRC report could read the entire surface and miss the safe
path (**A-G1 / B-G2**).
5. **One shared caveat applies to both surfaces.** Because scoping is
done by rewriting `conversationId` to `{escapedKey}::{conversationId}`
before forwarding, any inner store that logs, echoes, audits, or
constrains the shape of `conversationId` is silently affected. This
needs to be a documented contract on `IsolationKeyScopedAgentSessionStore`
(and ideally a one-line "treat `conversationId` as opaque" note on
`AgentSessionStore` itself). It applies identically to A2A
(**A-G3**) and AG-UI (**B-G3**).
### Combined scorecard
| MSRC ask (collapsed across both reports) | Status |
|---|---|
| Make principal-scoping *expressible* without rewriting every store | ✅ Decorator (`IsolationKeyScopedAgentSessionStore`) |
| Auto-engage scoping on the **A2A** canonical registration shape when an isolation provider is present | ✅ `CreateA2AServer` wraps |
| Auto-engage scoping on the **AG-UI** canonical registration shape when an isolation provider is present | ❌ `MapAGUI` does not wrap |
| Preserve out-of-box single-user/no-auth behaviour | ✅ `Strict = false` when no provider; `NoopAgentSessionStore` default unchanged |
| Trust-model XML on `AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, A2A entry points | ❌ Not addressed |
| Document the `conversationId`-mutation contract the decorator imposes | ❌ Not addressed |
| Ship enough provider variants to be discoverable beyond a single Claims case | ⚠️ Only `ClaimsIdentitySessionIsolationKeyProvider`, single-claim, no `Identity.Name` fallback |
| Working safe-path samples for both A2A and AG-UI | ❌ AG-UI sample is a commented reminder; no A2A sample update |
---
## Part D — Next steps (respecting "out-of-box default must keep working")
All of the following preserve the design constraint: **with no
isolation provider registered, behaviour must not change** — same
default store (`NoopAgentSessionStore`), same wire contract, same
prototype-friendly first-run experience. None of them require an
integrator to register auth or an isolation provider before persistence
works.
Listed in priority order, with the closure mapping back to the gap IDs
above.
1. **Mirror the A2A auto-wrap inside `MapAGUI`.** In the AG-UI endpoint
extension, after resolving the keyed `AgentSessionStore`, walk the
`GetService<IsolationKeyScopedAgentSessionStore>()` chain; if it is
absent, wrap with `Strict = isolationKeyProvider != null`. The
`Strict = false` branch keeps the "no provider registered → bare
`conversationId` passes through" behaviour the constraint requires.
The `Strict = true` branch engages only when the integrator has
already opted in by calling `UseClaimsBasedSessionIsolation(...)`.
*Closes B-G1 and B-G6 (asymmetry); produces no out-of-box behaviour
change.*
2. **Add the trust-model XML paragraph to the five legacy surfaces.**
`AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, the A2A
`CreateA2AServer` / `AsA2AServer` entry points. The text needs to
say: (a) `conversationId` / `ThreadId` / `contextId` arrives from
the wire and is not an authorization token; (b) `(agent, conversationId)`
has no principal dimension, so persistent stores are single-namespace
by default; (c) multi-user hosts should compose one via
`UseClaimsBasedSessionIsolation` or a custom
`SessionIsolationKeyProvider`. Pure docs change, no behaviour
impact. *Closes A-G1, B-G2.*
3. **Document the `conversationId`-mutation contract** on
`IsolationKeyScopedAgentSessionStore` and add a one-liner on
`AgentSessionStore` saying inner stores must treat `conversationId`
as opaque (don't parse, don't impose length/charset constraints,
expect logs/telemetry to surface it verbatim). *Closes A-G3, B-G3.*
4. **Convert the AG-UI sample into a working safe configuration** (and
add an equivalent slice to one A2A sample). The sample should
register an auth scheme, call `UseClaimsBasedSessionIsolation`, and
register the store via the canonical endpoint shape — so once
step 1 lands, copy-paste users land on the safe path automatically.
The unsafe path must still be reachable (constraint), but the
in-tree sample shouldn't model it. *Closes A-G2 follow-up, B-G5.*
5. **Widen the provider story.** Either extend
`ClaimsIdentitySessionIsolationKeyProvider` to take an ordered
fallback chain (e.g. `ClaimTypes.NameIdentifier``Identity.Name`),
or ship one or two additional providers (HTTP-header,
mTLS-subject) so the safe path is discoverable beyond a single
claims-based deployment. Whichever shape is chosen must continue to
return `null` for anonymous requests so the `Strict = false`
pass-through semantics keep holding. *Closes B-G4.*
### What is explicitly **not** proposed
- Flipping the default registered store from `NoopAgentSessionStore` to
any persistent default — out of scope per the constraint.
- Requiring `UseClaimsBasedSessionIsolation` (or any
`SessionIsolationKeyProvider`) before persistence works — out of
scope per the constraint.
- Adding a third `scopeId` parameter to `AgentSessionStore` virtuals
(would force every existing custom store to be recompiled and
re-implemented). The decorator approach already covers this case;
the only ask is a documented contract on inner stores.
- Treating an authenticated request without the configured claim as a
hard error in the no-isolation-provider scenario — only applies when
the integrator opted into `Strict = true` by registering a provider.
+1 -1
View File
@@ -242,6 +242,7 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
@@ -590,7 +591,6 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
Inputs = ["How do I use Azure OpenAI with my data?"],
InputDelayMs = 3000,
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeMcpTool",
@@ -9,42 +9,30 @@ namespace Harness.ConsoleReactiveComponents;
/// </summary>
public record TextPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the panel.</summary>
public IReadOnlyList<object> Items { get; init; } = [];
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
/// A component that renders a list of items vertically using a custom render delegate.
/// A component that renders a list of pre-rendered string items vertically.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
private readonly Func<object, string> _renderItem;
/// <summary>
/// Initializes a new instance of the <see cref="TextPanel"/> class.
/// </summary>
/// <param name="renderItem">A delegate that renders an item and returns the text to display (may contain newlines).</param>
public TextPanel(Func<object, string> renderItem)
{
this._renderItem = renderItem;
}
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <param name="renderItem">The render delegate to use for measuring.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<object> items, Func<object, string> renderItem)
public static int CalculateHeight(IReadOnlyList<string> items)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
string text = renderItem(items[i]);
total += CountLines(text);
total += CountLines(items[i]);
}
return total;
@@ -57,7 +45,7 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
for (int i = 0; i < props.Items.Count; i++)
{
string text = this._renderItem(props.Items[i]);
string text = props.Items[i];
string[] lines = text.Split('\n');
int lineCount = CountLines(text);
@@ -9,8 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
/// </summary>
public record TextScrollPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the scroll panel.</summary>
public IReadOnlyList<object> Items { get; init; } = [];
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
@@ -20,21 +21,17 @@ public record TextScrollPanelProps : ConsoleReactiveProps
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
/// <summary>
/// A component that renders items within a scroll area using a custom render delegate.
/// A component that renders pre-rendered string items within a scroll area.
/// All items are considered finalized — only new items since the last render are output.
/// Use <see cref="Reset"/> to force a full re-render.
/// </summary>
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
{
private readonly Func<object, string> _renderItem;
/// <summary>
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
/// </summary>
/// <param name="renderItem">A delegate that renders a single item and returns the text to display (may contain newlines).</param>
public TextScrollPanel(Func<object, string> renderItem)
public TextScrollPanel()
{
this._renderItem = renderItem;
this.State = new TextScrollPanelState();
}
@@ -60,8 +57,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
// Output only new items since last rendered
for (int i = state.RenderedCount; i < props.Items.Count; i++)
{
string text = this._renderItem(props.Items[i]);
Console.Write(text);
Console.Write(props.Items[i]);
}
// Update state to track what we've rendered
@@ -1,315 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleSandbox;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the list selection component.</summary>
ListSelection,
/// <summary>Show the text input component.</summary>
TextInput
}
public record AppComponentProps : ConsoleReactiveProps
{
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
public IReadOnlyList<object> ScrollItems { get; init; } = [];
/// <summary>Gets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection;
/// <summary>Gets the prompt string for text input mode.</summary>
public string Prompt { get; init; } = "> ";
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; init; } = "";
/// <summary>Gets the highlight color for the active list item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
/// <summary>Gets the placeholder text for the custom text input option in the list. If <c>null</c>, no custom option is shown.</summary>
public string? ListCustomTextPlaceholder { get; init; }
/// <summary>Gets the foreground color for the rule borders. If <c>null</c>, uses the default terminal color.</summary>
public ConsoleColor? RuleColor { get; init; }
}
/// <summary>
/// Internal state for the <see cref="AppComponent"/>.
/// </summary>
public record AppComponentState : ConsoleReactiveState
{
/// <summary>Gets the selected index in list selection mode.</summary>
public int SelectedIndex { get; init; }
/// <summary>Gets the current input text being typed in text input mode.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListInputText { get; init; } = "";
}
public class AppComponent : ConsoleReactiveComponent<AppComponentProps, AppComponentState>
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel;
private readonly TextPanel _textPanel;
private readonly Func<object, string> _renderItem;
private readonly Action<string> _onTextInputSubmit;
private readonly Action<string> _onListInputSubmit;
private bool _resizedSinceLastRender;
private int _lastScrollBottom;
/// <summary>
/// Initializes a new instance of the <see cref="AppComponent"/> class.
/// </summary>
/// <param name="renderScrollItem">A delegate that renders a single scroll panel item and returns the text to display.</param>
/// <param name="onTextInputSubmit">A callback invoked with the input text when the user presses Enter in text input mode.</param>
/// <param name="onListInputSubmit">A callback invoked with the selected or typed text when the user presses Enter in list selection mode.</param>
public AppComponent(Func<object, string> renderScrollItem, Action<string> onTextInputSubmit, Action<string> onListInputSubmit)
{
this._renderItem = renderScrollItem;
this._onTextInputSubmit = onTextInputSubmit;
this._onListInputSubmit = onListInputSubmit;
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
this._textPanel = new TextPanel(renderScrollItem);
this.State = new AppComponentState();
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
}
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
{
if (this.Props!.Mode == BottomPanelMode.TextInput)
{
this.HandleTextInputKey(e);
}
else
{
this.HandleListSelectionKey(e);
}
}
private void HandleTextInputKey(KeyPressEventArgs e)
{
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
this.SetState(this.State with { InputText = "" });
this._onTextInputSubmit(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.InputText.Length > 0)
{
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
}
}
private void HandleListSelectionKey(KeyPressEventArgs e)
{
int maxIndex = this.Props!.Items.Count - 1;
if (this.Props.ListCustomTextPlaceholder != null)
{
maxIndex = this.Props.Items.Count; // extra option at the end
}
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
&& this.State!.SelectedIndex == this.Props.Items.Count;
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.Enter)
{
if (isOnCustomTextOption)
{
string text = this.State!.ListInputText;
this.SetState(this.State with { ListInputText = "" });
this._onListInputSubmit(text);
}
else
{
this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]);
}
}
else if (isOnCustomTextOption)
{
// Typing only works when on the custom text option
if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.ListInputText.Length > 0)
{
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
}
}
}
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
{
this._resizedSinceLastRender = true;
this.Render();
}
public override void RenderCore(AppComponentProps props, AppComponentState state)
{
// Determine the text panel height for the last scroll item
object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null;
IReadOnlyList<object> lastItems = lastItem != null ? [lastItem] : [];
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
if (textPanelHeight > 0)
{
textPanelHeight++; // Extra line for spacing between text panel and rule
}
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
int bottomChildHeight;
if (props.Mode == BottomPanelMode.TextInput)
{
var textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth);
this._textInput.Width = Console.WindowWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
else
{
var listProps = new ListSelectionProps
{
Items = props.Items,
SelectedIndex = state.SelectedIndex,
HighlightColor = props.ListHighlightColor,
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
CustomText = state.ListInputText
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
var ruleProps = new TopBottomRuleProps
{
Width = Console.WindowWidth,
Color = props.RuleColor,
Children = [bottomChild]
};
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight;
// If scroll region changed or a clear is needed, reset everything
if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom))
{
Console.Write(AnsiEscapes.EraseEntireScreen);
Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
}
this._lastScrollBottom = scrollBottom;
Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
// Render text scroll panel in the scroll area (all items except the last)
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = Console.WindowWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
Items = scrollItems
};
this._textScrollPanel.Render();
// Render the text panel for the last (dynamic) item just below the scroll region
this._textPanel.X = 1;
this._textPanel.Y = scrollBottom + 1;
this._textPanel.Width = Console.WindowWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
Items = lastItems,
};
this._textPanel.Render();
// Render the bottom rule + child below the text panel
this._rule.X = 1;
this._rule.Y = scrollBottom + textPanelHeight + 1;
this._rule.Props = ruleProps;
this._rule.Render();
// Position cursor for natural typing appearance
if (props.Mode == BottomPanelMode.TextInput)
{
int promptLength = props.Prompt.Length;
int textWidth = Console.WindowWidth - promptLength;
int textLength = state.InputText.Length;
// The TextInput starts at rule.Y + 1 (first row inside the rule)
int textInputY = this._rule.Y + 1;
if (textWidth <= 0 || textLength == 0)
{
// Cursor right after the prompt
Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
}
else
{
// Calculate which row and column the cursor lands on
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
}
}
else if (props.Mode == BottomPanelMode.ListSelection
&& props.ListCustomTextPlaceholder != null
&& state.SelectedIndex == props.Items.Count)
{
// Cursor after the typed text in the custom text option
// The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule)
int customOptionY = this._rule.Y + 1 + props.Items.Count;
// "> " prefix is 2 chars, then the typed text
int cursorCol = 2 + state.ListInputText.Length + 1;
Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
}
}
@@ -23,7 +23,7 @@ public abstract class CommandHandler
/// </summary>
/// <param name="input">The raw user input string.</param>
/// <param name="session">The current agent session.</param>
/// <param name="ux">The UX container for rendering output.</param>
/// <param name="ux">The UX state driver for rendering output.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux);
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/exit</c> command to shut down the console application.
/// </summary>
public sealed class ExitCommandHandler : CommandHandler
{
/// <inheritdoc/>
public override string? GetHelpText() => "/exit (quit)";
/// <inheritdoc/>
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
{
return new ValueTask<bool>(false);
}
ux.RequestShutdown();
return new ValueTask<bool>(true);
}
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
/// </summary>
internal sealed class ModeCommandHandler : CommandHandler
public sealed class ModeCommandHandler : CommandHandler
{
private readonly AgentModeProvider? _modeProvider;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
@@ -27,7 +27,7 @@ internal sealed class ModeCommandHandler : CommandHandler
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/todos</c> command to display the current todo list.
/// </summary>
internal sealed class TodoCommandHandler : CommandHandler
public sealed class TodoCommandHandler : CommandHandler
{
private readonly TodoProvider? _todoProvider;
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : CommandHandler
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Represents an action returned by an observer at the end of an agent turn.
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
/// </summary>
public abstract record FollowUpAction;
/// <summary>
/// Represents a question that should be presented to the user. The
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
/// next agent invocation.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">
/// Invoked with the user's answer and the UX state driver. The driver lets the
/// continuation write output (e.g., an action label like "Approved") in addition
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
/// </param>
public abstract record FollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
/// <summary>
/// A free-form text question. The user may type any response.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record TextFollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Choices">The list of pre-defined choices.</param>
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record ChoiceFollowUpQuestion(
string Prompt,
IReadOnlyList<string> Choices,
bool AllowCustomText,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A message to add directly to the next agent invocation without prompting the user.
/// </summary>
/// <param name="Message">The chat message to add.</param>
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
@@ -0,0 +1,279 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Orchestrates agent invocations driven by user-input events from the UI.
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
/// All per-turn follow-up state (pending questions and accumulated responses) lives
/// in the component's state record — the runner reads/writes it exclusively through
/// the driver and holds no per-turn fields itself.
/// </summary>
public sealed class HarnessAgentRunner : IDisposable
{
private readonly AIAgent _agent;
private readonly AgentSession _session;
private readonly AgentModeProvider? _modeProvider;
private readonly MessageInjectingChatClient? _messageInjector;
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
private readonly IReadOnlyList<ConsoleObserver> _observers;
private readonly IUXStateDriver _ux;
private readonly SemaphoreSlim _inputGate = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
/// </summary>
public HarnessAgentRunner(
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
MessageInjectingChatClient? messageInjector,
IReadOnlyList<CommandHandler> commandHandlers,
IReadOnlyList<ConsoleObserver> observers,
IUXStateDriver ux)
{
this._agent = agent;
this._session = session;
this._modeProvider = modeProvider;
this._messageInjector = messageInjector;
this._commandHandlers = commandHandlers;
this._observers = observers;
this._ux = ux;
this.HelpText = string.Join(
", ",
commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)!);
}
/// <summary>
/// Gets the help text describing all available commands (joined by ", "), suitable
/// for display in the mode-and-help bar. Computed from the supplied
/// <c>commandHandlers</c>.
/// </summary>
public string HelpText { get; }
/// <inheritdoc/>
public void Dispose() => this._inputGate.Dispose();
/// <summary>
/// Handles a top-level user input submission (TextInput mode, no pending question).
/// Dispatches to command handlers, or starts an agent turn.
/// </summary>
internal async Task OnUserInputAsync(string text)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._ux.WriteUserInputEcho(text);
foreach (var handler in this._commandHandlers)
{
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
{
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
return;
}
}
await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
}
finally
{
this._inputGate.Release();
}
}
/// <summary>
/// Handles a user input submission while an agent turn is streaming. The text is
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
/// by the agent on its next opportunity.
/// </summary>
internal Task OnStreamingInputAsync(string text)
{
if (this._messageInjector is null)
{
return Task.CompletedTask;
}
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
return Task.CompletedTask;
}
/// <summary>
/// Resumes (or completes) a turn after the user has answered all pending follow-up
/// questions. The component invokes this with the messages drained from
/// <see cref="IUXStateDriver.TakeFollowUpResponses"/>; an empty list simply ends
/// the streaming display state without invoking the agent.
/// </summary>
internal async Task StartAgentTurnAsync(IList<ChatMessage> messages)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
if (messages.Count == 0)
{
this.CompleteTurn();
return;
}
await this.RunAgentLoopAsync(messages).ConfigureAwait(false);
}
finally
{
this._inputGate.Release();
}
}
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
{
IList<ChatMessage>? nextMessages = messages;
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
while (nextMessages is not null)
{
var runOptions = new AgentRunOptions();
foreach (var observer in this._observers)
{
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
}
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.BeginStreaming();
this._ux.BeginStreamingOutput();
try
{
await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions))
{
if (this._modeProvider is not null)
{
string currentMode = this._modeProvider.GetMode(this._session);
if (currentMode != this._ux.CurrentMode)
{
this._ux.CurrentMode = currentMode;
}
}
foreach (var content in update.Contents)
{
foreach (var observer in this._observers)
{
await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false);
}
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in this._observers)
{
await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false);
}
}
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
}
}
catch (Exception ex)
{
await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false);
}
// Final sync after streaming.
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
this._ux.StopSpinner();
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
// Collect FollowUpActions from each observer.
var directMessages = new List<ChatMessage>();
var questions = new List<FollowUpQuestion>();
foreach (var observer in this._observers)
{
var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false);
if (actions is null)
{
continue;
}
foreach (var action in actions)
{
switch (action)
{
case FollowUpMessage msg:
directMessages.Add(msg.Message);
break;
case FollowUpQuestion q:
questions.Add(q);
break;
}
}
}
bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0;
await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false);
// Add any direct messages to the accumulator regardless of whether questions follow —
// they're sent on the next agent invocation, either by us (if no questions) or by
// the component (after the user finishes answering, via StartAgentTurnAsync).
foreach (var msg in directMessages)
{
this._ux.AddFollowUpResponse(msg);
}
if (questions.Count > 0)
{
// Pause: hand control back to the UX to collect answers.
this._ux.QueueFollowUpQuestions(questions);
return;
}
// No questions to ask — drain anything we just accumulated and loop with it.
IReadOnlyList<ChatMessage> drained = this._ux.TakeFollowUpResponses();
nextMessages = drained.Count > 0 ? [.. drained] : null;
}
this.CompleteTurn();
}
private void CompleteTurn()
{
this._ux.EndStreaming();
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
}
/// <summary>
/// Synchronizes the queued items display with the message injector's pending messages.
/// Messages that have been consumed (drained by the service) are echoed to the output
/// area as regular user-input entries.
/// </summary>
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (this._messageInjector is null)
{
return;
}
var pending = this._messageInjector.GetPendingMessages(this._session);
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
{
string text = lastPendingMessages[i].Text ?? string.Empty;
this._ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
this._ux.SetQueuedMessages(pending);
}
}
@@ -3,171 +3,89 @@
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
using Harness.Shared.Console.Components;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the text input component for user input.</summary>
TextInput,
/// <summary>Show the list selection component for interactive prompts.</summary>
ListSelection,
/// <summary>Show a disabled input indicator during agent streaming.</summary>
Streaming,
}
/// <summary>
/// Event arguments for the <see cref="HarnessAppComponent.InputSubmitted"/> event.
/// </summary>
public sealed class InputSubmittedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="InputSubmittedEventArgs"/> class.
/// </summary>
/// <param name="text">The submitted text.</param>
/// <param name="mode">The bottom panel mode in which the input was submitted.</param>
public InputSubmittedEventArgs(string text, BottomPanelMode mode)
{
this.Text = text;
this.Mode = mode;
}
/// <summary>Gets the submitted text.</summary>
public string Text { get; }
/// <summary>Gets the bottom panel mode in which the input was submitted.</summary>
public BottomPanelMode Mode { get; }
}
/// <summary>
/// Props for <see cref="HarnessAppComponent"/>.
/// </summary>
public record HarnessAppComponentProps : ConsoleReactiveProps
{
/// <summary>Gets or sets the list selection choices (for ListSelection mode).</summary>
public IReadOnlyList<string> Items { get; set; } = Array.Empty<string>();
/// <summary>Gets or sets the scroll items (output entries) to render in the scroll panel.</summary>
public IReadOnlyList<object> ScrollItems { get; set; } = [];
/// <summary>Gets or sets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput;
/// <summary>Gets or sets the prompt string for text input mode.</summary>
public string Prompt { get; set; } = "You: ";
/// <summary>Gets or sets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; set; } = "";
/// <summary>Gets or sets the highlight color for the active list item.</summary>
public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan;
/// <summary>Gets or sets the placeholder text for the custom text input option in the list.</summary>
public string? ListCustomTextPlaceholder { get; set; }
/// <summary>Gets or sets the foreground color for the rule borders and mode label.</summary>
public ConsoleColor? ModeColor { get; set; }
/// <summary>Gets or sets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
public string? ModeText { get; set; }
/// <summary>Gets or sets the help text displayed below the bottom rule (available commands).</summary>
public string? HelpText { get; set; }
/// <summary>Gets or sets the title text displayed above the list selection (for interactive prompts).</summary>
public string? ListTitle { get; set; }
/// <summary>Gets or sets a value indicating whether input is enabled during streaming.</summary>
public bool InputEnabled { get; set; }
/// <summary>Gets or sets the prompt to show during streaming when input is disabled.</summary>
public string StreamingPrompt { get; set; } = "(agent is running...)";
/// <summary>Gets or sets a value indicating whether the agent status spinner is visible.</summary>
public bool ShowSpinner { get; set; }
/// <summary>Gets or sets the formatted token usage text to display in the status bar.</summary>
public string? UsageText { get; set; }
/// <summary>Gets or sets the queued input items to display above the rule.</summary>
public IReadOnlyList<object> QueuedItems { get; set; } = [];
}
/// <summary>
/// Internal state for <see cref="HarnessAppComponent"/>.
/// </summary>
public record HarnessAppComponentState : ConsoleReactiveState
{
/// <summary>Gets the selected index in list selection mode.</summary>
public int SelectedIndex { get; init; }
/// <summary>Gets the current input text being typed.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListInputText { get; init; } = "";
/// <summary>Gets the current console width in columns.</summary>
public int ConsoleWidth { get; init; }
/// <summary>Gets the current console height in rows.</summary>
public int ConsoleHeight { get; init; }
}
/// <summary>
/// The main application component for the Harness console. Manages the scroll region
/// and bottom panel (text input, list selection, or streaming indicator), and emits
/// an <see cref="InputSubmitted"/> event when the user submits text in any mode.
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
/// registered <see cref="HarnessAgentRunner"/>.
/// </summary>
public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentProps, HarnessAppComponentState>, IDisposable
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, HarnessAppComponentState>, IDisposable
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel;
private readonly TextPanel _textPanel;
private readonly TextPanel _queuedPanel;
private readonly TextScrollPanel _textScrollPanel = new();
private readonly TextPanel _textPanel = new();
private readonly TextPanel _queuedPanel = new();
private readonly AgentStatus _agentStatus = new();
private readonly AgentModeAndHelp _modeAndHelp = new();
private readonly Func<object, string> _renderItem;
private bool _resizedSinceLastRender;
private readonly HarnessConsoleUXStateDriver _uxDriver;
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _followUpGate = new(1, 1);
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
/// </summary>
/// <param name="renderScrollItem">A delegate that renders a single output entry and returns the text to display.</param>
public HarnessAppComponent(Func<object, string> renderScrollItem)
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
/// <param name="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessAppComponent(
string placeholder,
string? initialMode,
bool inputEnabled,
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._renderItem = renderScrollItem;
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
this._textPanel = new TextPanel(renderScrollItem);
this._queuedPanel = new TextPanel(renderScrollItem);
this.Props = new ConsoleReactiveProps();
this.State = new HarnessAppComponentState
{
Mode = BottomPanelMode.TextInput,
Prompt = "> ",
Placeholder = placeholder,
ModeColor = ModeColors.Get(initialMode, modeColors),
ModeText = initialMode,
InputEnabled = inputEnabled,
ConsoleWidth = System.Console.WindowWidth,
ConsoleHeight = System.Console.WindowHeight,
};
this._uxDriver = new HarnessConsoleUXStateDriver(
getState: () => this.State!,
setState: s => this.SetState(s),
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
modeColors: modeColors);
this.Runner = runnerFactory(this._uxDriver);
// Seed help text now that the runner (which knows the registered command handlers)
// is available. Direct assignment — no Render is triggered until the caller invokes Render().
this.State = this.State with { HelpText = this.Runner.HelpText };
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
}
/// <summary>
/// Gets the 1-based row number of the last row in the output scroll region.
/// Gets the agent runner that owns the agent loop. Constructed by the factory
/// passed to the component's constructor.
/// </summary>
public int ScrollRegionBottom { get; private set; }
public HarnessAgentRunner Runner { get; }
/// <summary>
/// Occurs when the user submits input via Enter, in any mode (text input, list selection,
/// or streaming injection). Consumers inspect <see cref="InputSubmittedEventArgs.Mode"/>
/// to decide how to handle the submission.
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
/// </summary>
public event EventHandler<InputSubmittedEventArgs>? InputSubmitted;
public Task ShutdownTask => this._shutdownTcs.Task;
/// <summary>
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
@@ -184,9 +102,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
this._agentStatus.Dispose();
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.MoveCursor(System.Console.WindowHeight, 1));
System.Console.WriteLine();
}
/// <inheritdoc/>
@@ -205,20 +120,23 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
if (disposing)
{
this.Deactivate();
this._followUpGate.Dispose();
this.Runner.Dispose();
}
}
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
{
if (this.Props!.Mode == BottomPanelMode.TextInput)
BottomPanelMode mode = this.State!.Mode;
if (mode == BottomPanelMode.TextInput)
{
this.HandleTextInputKey(e);
}
else if (this.Props.Mode == BottomPanelMode.ListSelection)
else if (mode == BottomPanelMode.ListSelection)
{
this.HandleListSelectionKey(e);
}
else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.InputEnabled)
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
{
this.HandleStreamingInputKey(e);
}
@@ -235,7 +153,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
}
this.SetState(this.State with { InputText = "" });
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput));
this.DispatchTextInputSubmission(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
@@ -252,51 +170,50 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
private void HandleListSelectionKey(KeyPressEventArgs e)
{
int maxIndex = this.Props!.Items.Count - 1;
if (this.Props.ListCustomTextPlaceholder != null)
int maxIndex = this.State!.ListSelectionOptions.Count - 1;
if (this.State.ListSelectionCustomTextPlaceholder != null)
{
maxIndex = this.Props.Items.Count;
maxIndex = this.State.ListSelectionOptions.Count;
}
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
&& this.State!.SelectedIndex == this.Props.Items.Count;
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string result = isOnCustomTextOption
? this.State!.ListInputText
: this.Props.Items[this.State!.SelectedIndex];
? this.State.ListSelectionCustomInputText
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 });
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection));
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
this.DispatchListSelectionSubmission(result);
}
else if (isOnCustomTextOption)
{
if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.ListInputText.Length > 0)
if (this.State.ListSelectionCustomInputText.Length > 0)
{
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar });
}
}
}
private void HandleStreamingInputKey(KeyPressEventArgs e)
{
// During streaming with input enabled, capture text for message injection
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
@@ -306,7 +223,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
}
this.SetState(this.State with { InputText = "" });
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming));
_ = this.Runner.OnStreamingInputAsync(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
@@ -321,6 +238,90 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
}
}
private void DispatchTextInputSubmission(string text)
{
if (this.State!.PendingQuestions.Count > 0)
{
_ = this.HandleFollowUpAnswerAsync(text);
}
else
{
_ = this.Runner.OnUserInputAsync(text);
}
}
private void DispatchListSelectionSubmission(string text)
{
// List selection is only used to answer FollowUpQuestions.
_ = this.HandleFollowUpAnswerAsync(text);
}
/// <summary>
/// Handles a user answer to the head of the pending follow-up question queue:
/// awaits the question's continuation (which is responsible for echoing both the
/// question and answer to the scroll area as it sees fit), appends any returned
/// chat message to the response accumulator, advances the queue, and — when the
/// queue empties — drains the accumulator and resumes the runner.
/// </summary>
private async Task HandleFollowUpAnswerAsync(string text)
{
IReadOnlyList<ChatMessage>? messagesToSend = null;
await this._followUpGate.WaitAsync().ConfigureAwait(false);
try
{
HarnessConsoleUXStateDriver ux = this._uxDriver;
IReadOnlyList<FollowUpQuestion> queue = this.State!.PendingQuestions;
if (queue.Count == 0)
{
return;
}
FollowUpQuestion head = queue[0];
ChatMessage? response;
try
{
response = await head.Continuation(text, ux).ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
response = null;
}
if (response is not null)
{
ux.AddFollowUpResponse(response);
}
ux.AdvanceFollowUpQuestion();
if (this.State!.PendingQuestions.Count == 0)
{
messagesToSend = ux.TakeFollowUpResponses();
}
}
finally
{
this._followUpGate.Release();
}
// Resume the agent outside the gate — StartAgentTurnAsync runs the full agent
// loop which may queue new follow-up questions (re-entering this method).
if (messagesToSend is not null)
{
try
{
await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false);
}
catch (Exception ex)
{
await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
}
}
}
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
{
this._resizedSinceLastRender = true;
@@ -332,35 +333,40 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
}
/// <inheritdoc />
public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state)
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
{
if (this._deactivated)
{
return;
}
// Determine the text panel height for the last scroll item
IReadOnlyList<object> lastItems = props.ScrollItems.Count > 0
? [props.ScrollItems[^1]]
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
? [state.ScrollAreaContentItems[^1]]
: [];
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
if (textPanelHeight > 0)
{
textPanelHeight++; // Extra line for spacing between text panel and rule
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem);
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
int bottomChildHeight;
if (props.Mode == BottomPanelMode.ListSelection)
if (state.Mode == BottomPanelMode.ListSelection)
{
var listProps = new ListSelectionProps
{
Title = props.ListTitle,
Items = props.Items,
SelectedIndex = state.SelectedIndex,
HighlightColor = props.ListHighlightColor,
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
CustomText = state.ListInputText,
Title = state.ListSelectionTitle,
Items = state.ListSelectionOptions,
SelectedIndex = state.ListSelectionIndex,
HighlightColor = state.ListHighlightColor,
CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder,
CustomText = state.ListSelectionCustomInputText,
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
@@ -368,25 +374,25 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
else if (props.Mode == BottomPanelMode.Streaming)
else if (state.Mode == BottomPanelMode.Streaming)
{
TextInputProps textInputProps;
if (props.InputEnabled)
if (state.InputEnabled)
{
textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Prompt = state.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder,
Placeholder = state.Placeholder,
};
}
else
{
textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Prompt = state.Prompt,
Text = "",
Placeholder = props.StreamingPrompt,
Placeholder = state.StreamingPrompt,
};
}
@@ -400,9 +406,9 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
{
var textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Prompt = state.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder,
Placeholder = state.Placeholder,
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
@@ -415,46 +421,52 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
Color = props.ModeColor,
Color = state.ModeColor,
Children = [bottomChild],
};
// Calculate the agent status height
var agentStatusProps = new AgentStatusProps
{
ShowSpinner = props.ShowSpinner,
UsageText = props.UsageText,
ShowSpinner = state.ShowSpinner,
UsageText = state.UsageText,
};
int agentStatusHeight = AgentStatus.CalculateHeight(agentStatusProps);
// Calculate the mode-and-help height
var modeAndHelpProps = new AgentModeAndHelpProps
{
Mode = props.ModeText,
ModeColor = props.ModeColor,
HelpText = props.HelpText,
Mode = state.ModeText,
ModeColor = state.ModeColor,
HelpText = state.HelpText,
};
int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps);
// Hide agent status and mode/help during follow-up questions (ListSelection mode)
// as they clutter the UI and aren't relevant.
bool showStatusAndHelp = state.Mode != BottomPanelMode.ListSelection;
int agentStatusHeight = showStatusAndHelp ? AgentStatus.CalculateHeight(agentStatusProps) : 0;
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight);
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
// If scroll region changed or a clear is needed, reset everything
if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom))
if (this._resizedSinceLastRender || (this._scrollRegionBottom != 0 && scrollBottom != this._scrollRegionBottom))
{
// Reset scroll region to full screen before erasing so the erase covers all rows —
// some terminals only erase within the active DECSTBM region.
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
}
this.ScrollRegionBottom = scrollBottom;
this._scrollRegionBottom = scrollBottom;
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
// Render text scroll panel in the scroll area (all items except the last)
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
@@ -486,18 +498,21 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
this._queuedPanel.Height = queuedPanelHeight;
this._queuedPanel.Props = new TextPanelProps
{
Items = props.QueuedItems,
Items = state.QueuedItems,
};
this._queuedPanel.Render();
// Render the agent status line between queued items and rule
int agentStatusY = queuedPanelY + queuedPanelHeight;
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Render();
if (showStatusAndHelp)
{
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Render();
}
// Render the bottom rule + child below the agent status
this._rule.X = 1;
@@ -506,24 +521,27 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
this._rule.Render();
// Render the mode-and-help line below the bottom rule
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
this._modeAndHelp.Render();
if (showStatusAndHelp)
{
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
this._modeAndHelp.Render();
}
// Position cursor for natural typing appearance
this.PositionCursor(props, state);
this.PositionCursor(state);
}
private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state)
private void PositionCursor(HarnessAppComponentState state)
{
if (props.Mode == BottomPanelMode.TextInput
|| (props.Mode == BottomPanelMode.Streaming && props.InputEnabled))
if (state.Mode == BottomPanelMode.TextInput
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
{
int promptLength = props.Prompt.Length;
int promptLength = state.Prompt.Length;
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
@@ -540,13 +558,13 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
}
}
else if (props.Mode == BottomPanelMode.ListSelection
&& props.ListCustomTextPlaceholder != null
&& state.SelectedIndex == props.Items.Count)
else if (state.Mode == BottomPanelMode.ListSelection
&& state.ListSelectionCustomTextPlaceholder != null
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
{
int titleLines = props.ListTitle?.Split('\n').Length ?? 0;
int customOptionY = this._rule.Y + 1 + titleLines + props.Items.Count;
int cursorCol = 2 + state.ListInputText.Length + 1;
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the text input component for user input.</summary>
TextInput,
/// <summary>Show the list selection component for interactive prompts.</summary>
ListSelection,
/// <summary>Show a disabled input indicator during agent streaming.</summary>
Streaming,
}
/// <summary>
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
/// change after construction live here; they are mutated exclusively via
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
/// </summary>
public record HarnessAppComponentState : ConsoleReactiveState
{
// --- Console dimensions ---
/// <summary>Gets the current console width in columns.</summary>
public int ConsoleWidth { get; init; }
/// <summary>Gets the current console height in rows.</summary>
public int ConsoleHeight { get; init; }
// --- Bottom panel mode ---
/// <summary>Gets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
/// <summary>
/// Gets the queue of follow-up questions waiting for user answers. The head
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
/// are dispatched in order as each is answered. While this queue is non-empty,
/// the next user submission is treated as the answer to the head question
/// instead of going to the agent runner's normal input handler.
/// </summary>
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
/// <summary>
/// Gets the accumulated follow-up response messages collected during the
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
/// by observers and continuation results from answered questions. Consumed
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
/// before the next agent invocation.
/// </summary>
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
// --- Text input (active in TextInput / Streaming modes) ---
/// <summary>Gets the prompt string for text input mode.</summary>
public string Prompt { get; init; } = "> ";
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; init; } = "";
/// <summary>Gets the current input text being typed.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
public bool InputEnabled { get; init; }
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
public string StreamingPrompt { get; init; } = "(agent is running...)";
// --- List selection (active in ListSelection mode) ---
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
public string? ListSelectionTitle { get; init; }
/// <summary>Gets the list selection options.</summary>
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
/// <summary>Gets the highlighted option index in list selection mode.</summary>
public int ListSelectionIndex { get; init; }
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
public string? ListSelectionCustomTextPlaceholder { get; init; }
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListSelectionCustomInputText { get; init; } = "";
/// <summary>Gets the highlight color for the active list item.</summary>
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
// --- Scroll / output area ---
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
/// <summary>Gets the queued input items to display above the rule. Each item is a
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> QueuedItems { get; init; } = [];
// --- Agent mode + status display ---
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
public ConsoleColor? ModeColor { get; init; }
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
public string? ModeText { get; init; }
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
public string? HelpText { get; init; }
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
public bool ShowSpinner { get; init; }
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
public string? UsageText { get; init; }
}
@@ -1,9 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -15,244 +13,58 @@ public static class HarnessConsole
{
/// <summary>
/// Runs an interactive console session with the specified agent.
/// Supports streaming output, tool call display, spinner animation,
/// optional planning UX with structured output, and the <c>/todos</c> command.
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
/// (which completes when the user types <c>/exit</c>).
/// </summary>
/// <param name="agent">The agent to interact with.</param>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
/// <param name="options">Optional configuration options for the console session.</param>
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
{
options ??= new();
if (options.EnablePlanningUx
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
{
throw new ArgumentException(
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
nameof(options));
}
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
var commandHandlers = options.CommandHandlers
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
var commandHandlers = new List<CommandHandler>
{
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, options.ModeColors),
};
AgentSession session = await agent.CreateSessionAsync();
using var ux = new HarnessUXContainer(
using var component = new HarnessAppComponent(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
inputEnabled: messageInjector is not null,
runnerFactory: ux => new HarnessAgentRunner(
agent: agent,
session: session,
modeProvider: modeProvider,
messageInjector: messageInjector,
commandHandlers: commandHandlers,
observers: observers,
ux: ux),
modeColors: options.ModeColors);
// Streaming-mode submissions are enqueued for injection; the queued display
// is then refreshed from the injector's current pending list.
ux.StreamingInputReceived += (sender, e) =>
// Trigger the initial render of the component now that state is seeded.
component.Render();
try
{
if (messageInjector is null)
{
return;
}
messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]);
ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session));
};
var commandHelp = commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)
.Append("exit (quit)")!;
ux.Initialize(title, commandHelp!, messageInjector is not null);
string userInput = await ux.WaitForInputAsync();
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
await component.ShutdownTask.ConfigureAwait(false);
}
finally
{
ux.WriteUserInputEcho(userInput);
// Check command handlers first — first one to handle wins.
bool handled = false;
foreach (var handler in commandHandlers)
{
if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false))
{
handled = true;
break;
}
}
if (!handled)
{
await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput);
}
ux.CurrentMode = modeProvider?.GetMode(session);
userInput = await ux.WaitForInputAsync();
component.Deactivate();
}
ux.Deactivate();
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
}
/// <summary>
/// Runs one or more agent invocations for a single user turn, using the current
/// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups
/// (e.g., planning clarification loops).
/// </summary>
private static async Task RunAgentTurnAsync(
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
MessageInjectingChatClient? messageInjector,
HarnessConsoleOptions options,
HarnessUXContainer ux,
string userInput)
{
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
IReadOnlyList<ChatMessage> lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? [];
while (nextMessages is not null)
{
var observers = CreateObservers(options, modeProvider, session);
var runOptions = new AgentRunOptions();
foreach (var observer in observers)
{
observer.ConfigureRunOptions(runOptions);
}
ux.CurrentMode = modeProvider?.GetMode(session);
ux.BeginStreaming();
ux.BeginStreamingOutput();
try
{
await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
{
// Update mode color if the mode changed during streaming.
if (modeProvider is not null)
{
string currentMode = modeProvider.GetMode(session);
if (currentMode != ux.CurrentMode)
{
ux.CurrentMode = currentMode;
}
}
foreach (var content in update.Contents)
{
foreach (var observer in observers)
{
await observer.OnContentAsync(ux, content);
}
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in observers)
{
await observer.OnTextAsync(ux, update.Text);
}
}
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
}
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
}
// Final sync after streaming — messages may have been consumed during the last iteration.
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
// Stop spinner before observer completions (which may prompt for input).
ux.StopSpinner();
// Close the streaming output to provide visual separation from observer output.
await ux.EndStreamingOutputAsync();
var combinedMessages = new List<ChatMessage>();
bool hasObserverMessages = false;
foreach (var observer in observers)
{
var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options);
if (messages is { Count: > 0 })
{
combinedMessages.AddRange(messages);
hasObserverMessages = true;
}
}
await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages);
ux.EndStreaming();
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
}
}
/// <summary>
/// Synchronizes the queued items display with the message injector's pending messages.
/// Messages that have been consumed (drained by the service) are echoed to the output
/// area as regular user-input entries.
/// </summary>
private static void SyncQueuedMessageDisplay(
MessageInjectingChatClient? messageInjector,
AgentSession session,
HarnessUXContainer ux,
ref IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (messageInjector is null)
{
return;
}
var pending = messageInjector.GetPendingMessages(session);
// If previously pending messages exceed current pending count, some were consumed.
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
{
string text = lastPendingMessages[i].Text ?? string.Empty;
ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
ux.ShowQueuedMessages(pending);
}
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
{
var observers = new List<ConsoleObserver>
{
new ToolCallDisplayObserver(),
new ToolApprovalObserver(),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
};
if (options.EnablePlanningUx
&& modeProvider is not null
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
{
observers.Add(new PlanningOutputObserver(modeProvider));
}
else
{
observers.Add(new TextOutputObserver());
}
return observers;
}
}
@@ -1,5 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.ObjectModel;
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console;
/// <summary>
@@ -8,45 +14,120 @@ namespace Harness.Shared.Console;
public class HarnessConsoleOptions
{
/// <summary>
/// Gets or sets the optional maximum context window size in tokens.
/// When set, token usage is displayed as a percentage of the budget.
/// Gets or sets the list of console observers that participate in the agent response
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
/// When <see langword="null"/> (the default), a default set of observers is used.
/// Set to an empty list to disable all observers.
/// </summary>
public int? MaxContextWindowTokens { get; set; }
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
/// <summary>
/// Gets or sets the optional maximum output tokens.
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
/// Gets or sets the list of command handlers to check before sending user input to the agent.
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
/// When <see langword="null"/> (the default), a default set of handlers is used.
/// Set to an empty list to disable all command handlers.
/// </summary>
public int? MaxOutputTokens { get; set; }
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the planning UX is enabled.
/// When <see langword="true"/> and the agent is in the mode specified by <see cref="PlanningModeName"/>,
/// the console uses structured output to present clarification questions and approval requests
/// instead of streaming free-form text.
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
/// </summary>
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnablePlanningUx { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode that activates the planning UX.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? PlanningModeName { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode to switch to when the user approves a plan.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? ExecutionModeName { get; set; }
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
{
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
});
/// <summary>
/// Gets or sets a mapping of agent mode names to console colors.
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Creates the default set of observers without planning support.
/// Includes tool call display, tool approval, error display, reasoning display,
/// usage display, and text output.
/// </summary>
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
public static List<ConsoleObserver> BuildDefaultObservers(
int? maxContextWindowTokens = null,
int? maxOutputTokens = null,
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
{
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
};
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new TextOutputObserver(),
];
}
/// <summary>
/// Creates the default set of observers with planning support.
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
/// <returns>A list of observers for a planning-enabled console session.</returns>
public static List<ConsoleObserver> BuildObserversWithPlanning(
AIAgent agent,
string planModeName,
string executionModeName,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
int? maxContextWindowTokens = null,
int? maxOutputTokens = null,
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
{
var modeProvider = agent.GetService<AgentModeProvider>()
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
];
}
/// <summary>
/// Creates the default set of command handlers.
/// Includes exit, todo, and mode command handlers.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <returns>A list of command handlers for a standard console session.</returns>
public static List<CommandHandler> BuildDefaultCommandHandlers(
AIAgent agent,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
return
[
new ExitCommandHandler(),
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
];
}
}
@@ -0,0 +1,408 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
/// <c>SetState</c>-style callback. Each public operation updates state and lets
/// the component's render-skip optimization handle the actual draw.
/// </summary>
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
{
private readonly Func<HarnessAppComponentState> _getState;
private readonly Action<HarnessAppComponentState> _setState;
private readonly Action _requestShutdown;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<string> _outputItems = [];
private readonly object _stateLock = new();
private OutputEntryType? _lastEntryType;
private bool _hasReceivedAnyText;
private OutputEntry? _currentStreamingEntry;
private int _currentStreamingEntryIndex = -1;
private string? _currentMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
/// </summary>
/// <param name="getState">Returns the component's current state.</param>
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessConsoleUXStateDriver(
Func<HarnessAppComponentState> getState,
Action<HarnessAppComponentState> setState,
Action requestShutdown,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._getState = getState;
this._setState = setState;
this._requestShutdown = requestShutdown;
this._modeColors = modeColors;
this._currentMode = getState().ModeText;
}
/// <inheritdoc/>
public string? CurrentMode
{
get => this._currentMode;
set
{
this.UpdateState(s =>
{
this._currentMode = value;
return s with
{
ModeColor = ModeColors.Get(value, this._modeColors),
ModeText = value,
};
});
}
}
/// <inheritdoc/>
public void BeginStreaming() =>
this.UpdateState(s => s with
{
Mode = BottomPanelMode.Streaming,
ShowSpinner = true,
});
/// <inheritdoc/>
public void StopSpinner() =>
this.UpdateState(s => s with { ShowSpinner = false });
/// <inheritdoc/>
public void EndStreaming() =>
this.UpdateState(s => s with
{
Mode = BottomPanelMode.TextInput,
ShowSpinner = false,
});
/// <inheritdoc/>
public void BeginStreamingOutput()
{
lock (this._stateLock)
{
this._hasReceivedAnyText = false;
this._currentStreamingEntry = null;
this._currentStreamingEntryIndex = -1;
}
}
/// <inheritdoc/>
public void SetUsageText(string usageText) =>
this.UpdateState(s => s with { UsageText = usageText });
/// <inheritdoc/>
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
{
var newQueued = new List<string>(pending.Count);
foreach (var msg in pending)
{
string text = msg.Text ?? string.Empty;
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
}
this.UpdateState(s => s with { QueuedItems = newQueued });
}
/// <inheritdoc/>
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
{
if (questions.Count == 0)
{
return;
}
this.UpdateState(s =>
{
bool wasEmpty = s.PendingQuestions.Count == 0;
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
combined.AddRange(s.PendingQuestions);
combined.AddRange(questions);
HarnessAppComponentState next = s with { PendingQuestions = combined };
if (wasEmpty)
{
next = this.ConfigureForHeadQuestion(next, combined[0]);
}
return next;
});
}
/// <inheritdoc/>
public void AddFollowUpResponse(ChatMessage response)
{
this.UpdateState(s =>
{
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
combined.AddRange(s.AccumulatedFollowUpResponses);
combined.Add(response);
return s with { AccumulatedFollowUpResponses = combined };
});
}
/// <inheritdoc/>
public void AdvanceFollowUpQuestion()
{
this.UpdateState(s =>
{
if (s.PendingQuestions.Count == 0)
{
return s;
}
var remaining = s.PendingQuestions.Skip(1).ToList();
HarnessAppComponentState next = s with { PendingQuestions = remaining };
if (remaining.Count > 0)
{
return this.ConfigureForHeadQuestion(next, remaining[0]);
}
return next with
{
Mode = BottomPanelMode.TextInput,
ListSelectionOptions = [],
ListSelectionTitle = null,
ListSelectionCustomTextPlaceholder = null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
};
});
}
/// <inheritdoc/>
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
{
return this.UpdateState(s =>
{
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
if (responses.Count == 0)
{
return (s, responses);
}
return (s with { AccumulatedFollowUpResponses = [] }, responses);
});
}
/// <summary>
/// Configures the bottom-panel display fields on the supplied state for the
/// given head question. For text questions, also writes the prompt as an
/// info line above the input row as a side effect.
/// </summary>
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
{
if (question is ChoiceFollowUpQuestion choice)
{
return state with
{
Mode = BottomPanelMode.ListSelection,
ListSelectionOptions = choice.Choices.ToList(),
ListSelectionTitle = choice.Prompt,
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
};
}
// Text question — prompt is rendered as an info line above the input row.
// We append entries and capture the scroll snapshot inline so the caller's
// single _setState picks up both the new output and the UI mode change.
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
return state with
{
Mode = BottomPanelMode.TextInput,
ListSelectionOptions = [],
ListSelectionTitle = null,
ListSelectionCustomTextPlaceholder = null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
ScrollAreaContentItems = scrollSnapshot,
};
}
/// <inheritdoc/>
public void WriteUserInputEcho(string text)
{
this.UpdateState(s =>
{
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {text}\n\n",
ConsoleColor.Green));
return s with { ScrollAreaContentItems = snapshot };
});
}
/// <inheritdoc/>
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: false);
/// <inheritdoc/>
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: true);
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
this.UpdateState(s =>
{
// Add a blank line separator when transitioning from streaming text or user input.
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
? "\n "
: " ";
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.InfoLine,
fullText,
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
return s with { ScrollAreaContentItems = snapshot };
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task WriteTextAsync(string text, ConsoleColor? color = null)
{
this.UpdateState(s =>
{
this._lastEntryType = OutputEntryType.StreamingText;
this._hasReceivedAnyText = true;
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
if (this._currentStreamingEntry is not null
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
{
// The streaming entry is still the last item — safe to replace in place.
this._currentStreamingEntry = this._currentStreamingEntry with
{
Text = this._currentStreamingEntry.Text + text,
};
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
}
else
{
// Either the first text delta or other entries (tool calls, info lines)
// were appended after the previous streaming entry — start a fresh one.
const string Prefix = "\n";
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
}
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task EndStreamingOutputAsync()
{
this.UpdateState(s =>
{
if (this._hasReceivedAnyText)
{
this._outputItems.Add(RenderEntry("\n", null));
this._currentStreamingEntry = null;
this._lastEntryType = OutputEntryType.StreamFooter;
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
}
return s;
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
{
if (!this._hasReceivedAnyText && !hasFollowUpActions)
{
this.UpdateState(s =>
{
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.StreamFooter,
" (no text response from agent)\n",
ConsoleColor.DarkYellow));
return s with { ScrollAreaContentItems = snapshot };
});
}
return Task.CompletedTask;
}
/// <summary>
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
/// the text unchanged when no color is specified). Output is appended to
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
/// and <see cref="TextPanel"/>.
/// </summary>
private static string RenderEntry(string text, ConsoleColor? color) =>
color.HasValue
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
: text;
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
{
lock (this._stateLock)
{
this._setState(update(this._getState()));
}
}
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
{
lock (this._stateLock)
{
var (newState, result) = update(this._getState());
this._setState(newState);
return result;
}
}
/// <summary>
/// Appends one or more output entries to the output list, updates
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
/// context (e.g. within an <see cref="UpdateState"/> callback).
/// </summary>
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
{
this.AppendOutputEntriesCore(entries);
return new List<string>(this._outputItems);
}
private void AppendOutputEntriesCore(OutputEntry[] entries)
{
foreach (OutputEntry entry in entries)
{
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
}
if (entries.Length > 0)
{
this._lastEntryType = entries[^1].Type;
}
}
/// <inheritdoc/>
public void RequestShutdown() => this._requestShutdown();
}
@@ -1,478 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Event arguments raised when the user submits text while the bottom panel is in
/// streaming mode (i.e. an agent turn is in progress).
/// </summary>
public sealed class StreamingInputReceivedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="StreamingInputReceivedEventArgs"/> class.
/// </summary>
/// <param name="text">The submitted text.</param>
public StreamingInputReceivedEventArgs(string text)
{
this.Text = text;
}
/// <summary>
/// Gets the submitted text.
/// </summary>
public string Text { get; }
}
/// <summary>
/// Façade over the harness UI: owns the <see cref="HarnessAppComponent"/>, manages
/// its props, dispatches input submissions, and provides the high-level read/write
/// operations used by observers, command handlers, and the harness loop.
/// </summary>
/// <remarks>
/// All callers interact with the UI exclusively through this class. The underlying
/// <see cref="HarnessAppComponent"/> and its props are an implementation detail and
/// must not be exposed.
/// </remarks>
public sealed class HarnessUXContainer : IDisposable
{
/// <summary>
/// The prompt displayed in the bottom-panel input area.
/// </summary>
private const string UserPrompt = "> ";
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<object> _outputItems = [];
private readonly HarnessAppComponent _appComponent;
private readonly object _outputLock = new();
private TaskCompletionSource<string>? _pendingInputTcs;
private OutputEntryType? _lastEntryType;
private bool _hasReceivedAnyText;
private OutputEntry? _currentStreamingEntry;
private string? _currentMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessUXContainer"/> class.
/// </summary>
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessUXContainer(
string placeholder,
string? initialMode,
bool inputEnabled,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._modeColors = modeColors;
this._currentMode = initialMode;
this._appComponent = new HarnessAppComponent(RenderOutputEntry)
{
Props = new HarnessAppComponentProps
{
ScrollItems = this._outputItems,
Mode = BottomPanelMode.TextInput,
Prompt = UserPrompt,
Placeholder = placeholder,
ModeColor = ModeColors.Get(initialMode, modeColors),
ModeText = initialMode,
InputEnabled = inputEnabled,
},
};
this._appComponent.InputSubmitted += this.OnInputSubmitted;
}
/// <summary>
/// Raised when the user submits text while the bottom panel is in streaming mode.
/// Subscribers typically enqueue the text into a message-injecting chat client.
/// </summary>
public event EventHandler<StreamingInputReceivedEventArgs>? StreamingInputReceived;
/// <summary>
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this
/// also refreshes the rule colour and bottom-panel prompt to match the new mode.
/// </summary>
public string? CurrentMode
{
get => this._currentMode;
set
{
this._currentMode = value;
this._appComponent.Props = this._appComponent.Props! with
{
ModeColor = ModeColors.Get(value, this._modeColors),
ModeText = value,
};
this._appComponent.Render();
}
}
/// <summary>
/// Performs the initial screen clear, sets the help text in the mode-and-help bar,
/// and adds the title to the output area.
/// </summary>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="commandHelpTexts">The command help strings displayed in the mode-and-help bar.</param>
/// <param name="messageInjectionActive">Whether streaming-time message injection is enabled.</param>
public void Initialize(string title, IEnumerable<string> commandHelpTexts, bool messageInjectionActive)
{
// Set the help text on the mode-and-help bar (persists below the rule).
this._appComponent.Props = this._appComponent.Props! with
{
HelpText = string.Join(", ", commandHelpTexts),
ModeText = this._currentMode,
};
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._appComponent.Render();
this.AppendOutputEntries(
new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White),
new OutputEntry(OutputEntryType.InfoLine, "\n"));
}
/// <summary>
/// Restores the cursor and exits the alternate screen, ending the interactive UI.
/// </summary>
public void Deactivate() => this._appComponent.Deactivate();
/// <summary>
/// Switches the bottom panel to streaming mode and starts the spinner.
/// </summary>
public void BeginStreaming()
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.Streaming,
ShowSpinner = true,
};
this._appComponent.Render();
}
/// <summary>
/// Stops the spinner without leaving streaming mode. Use between the end of the
/// stream and any observer-driven prompts (e.g. tool approvals).
/// </summary>
public void StopSpinner()
{
this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false };
this._appComponent.Render();
}
/// <summary>
/// Switches the bottom panel back to text-input mode and stops the spinner.
/// </summary>
public void EndStreaming()
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.TextInput,
ShowSpinner = false,
};
this._appComponent.Render();
}
/// <summary>
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
/// </summary>
public void BeginStreamingOutput()
{
this._hasReceivedAnyText = false;
this._currentStreamingEntry = null;
}
/// <summary>
/// Sets the formatted usage text shown on the agent status bar.
/// </summary>
public void SetUsageText(string usageText)
{
this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText };
this._appComponent.Render();
}
/// <summary>
/// Clears the usage text from the agent status bar.
/// </summary>
public void ClearUsageText()
{
this._appComponent.Props = this._appComponent.Props! with { UsageText = null };
this._appComponent.Render();
}
/// <summary>
/// Replaces the queued-message display with one entry per pending message.
/// </summary>
public void ShowQueuedMessages(IReadOnlyList<ChatMessage> pending)
{
var newQueued = new List<object>(pending.Count);
foreach (var msg in pending)
{
string text = msg.Text ?? string.Empty;
newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray));
}
this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued };
this._appComponent.Render();
}
/// <summary>
/// Echoes a submitted user input as a regular user-input entry in the output area,
/// using the current mode-aware prompt prefix.
/// </summary>
/// <param name="text">The user-entered text.</param>
public void WriteUserInputEcho(string text)
{
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {text}\n",
ConsoleColor.Green));
}
/// <summary>
/// Writes informational output as an output entry, without a trailing newline.
/// </summary>
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: false);
/// <summary>
/// Writes informational output as an output entry, followed by a newline.
/// </summary>
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: true);
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
// Add a blank line separator when transitioning from streaming text or user input.
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
? "\n\n "
: " ";
string fullText = newLine ? prefix + text + "\n" : prefix + text;
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.InfoLine,
fullText,
color ?? ModeColors.Get(this.CurrentMode, this._modeColors)));
return Task.CompletedTask;
}
/// <summary>
/// Writes streaming text output from the agent. Successive calls accumulate into a
/// single streaming entry that is re-rendered by the text panel.
/// </summary>
public Task WriteTextAsync(string text, ConsoleColor? color = null)
{
lock (this._outputLock)
{
this._lastEntryType = OutputEntryType.StreamingText;
this._hasReceivedAnyText = true;
ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors);
if (this._currentStreamingEntry is not null)
{
this._currentStreamingEntry = this._currentStreamingEntry with
{
Text = this._currentStreamingEntry.Text + text,
};
this._outputItems[^1] = this._currentStreamingEntry;
}
else
{
const string Prefix = "\n";
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
this._outputItems.Add(this._currentStreamingEntry);
}
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
return Task.CompletedTask;
}
/// <summary>
/// Writes a blank-line separator to visually close the streaming output section.
/// Call before observer completions so their output is visually separated.
/// </summary>
public Task EndStreamingOutputAsync()
{
lock (this._outputLock)
{
this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n"));
this._currentStreamingEntry = null;
this._lastEntryType = OutputEntryType.StreamFooter;
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
return Task.CompletedTask;
}
/// <summary>
/// Shows a "(no text response from agent)" warning if no text was received
/// and no observer produced follow-up messages. Call after observer completions.
/// </summary>
/// <param name="hasFollowUpMessages">Whether any observer produced follow-up messages.</param>
public Task WriteNoTextWarningAsync(bool hasFollowUpMessages)
{
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
{
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.StreamFooter,
" (no text response from agent)\n",
ConsoleColor.DarkYellow));
}
return Task.CompletedTask;
}
/// <summary>
/// Reads a line of input from the user. If <paramref name="prompt"/> is supplied
/// it is rendered as an info line above the input row before reading.
/// </summary>
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
{
if (prompt is not null)
{
ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors);
this.AppendOutputEntries(
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor));
}
this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput };
this._appComponent.Render();
string input = await this.WaitForInputAsync();
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {input}\n",
ConsoleColor.Green));
return input;
}
/// <summary>
/// Presents a selection prompt with the given choices and waits for the user's
/// selection. The title is displayed above the list in the bottom panel. After
/// selection the bottom panel is restored to text-input mode and both the question
/// and selection are echoed in the output area.
/// </summary>
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.ListSelection,
Items = choices.ToList(),
ListTitle = title,
ListCustomTextPlaceholder = "✏️ Type a custom response...",
};
this._appComponent.Render();
string selection = await this.WaitForInputAsync();
this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput };
this.AppendOutputEntries(
new OutputEntry(
OutputEntryType.InfoLine,
$"\n {title}\n",
ModeColors.Get(this.CurrentMode, this._modeColors)),
new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {selection}\n",
ConsoleColor.Green));
return selection;
}
/// <summary>
/// Awaits the next non-streaming user input submission.
/// </summary>
public Task<string> WaitForInputAsync()
{
this._pendingInputTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
return this._pendingInputTcs.Task;
}
private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e)
{
if (e.Mode == BottomPanelMode.Streaming)
{
this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text));
}
else
{
var waiter = this._pendingInputTcs;
this._pendingInputTcs = null;
waiter?.TrySetResult(e.Text);
}
}
/// <inheritdoc/>
public void Dispose()
{
this._appComponent.InputSubmitted -= this.OnInputSubmitted;
this._appComponent.Deactivate();
this._appComponent.Dispose();
}
/// <summary>
/// Renders an <see cref="OutputEntry"/> to a string with ANSI color codes.
/// Used as the render delegate for the <see cref="HarnessAppComponent"/>.
/// </summary>
private static string RenderOutputEntry(object item)
{
if (item is not OutputEntry entry)
{
return item?.ToString() ?? string.Empty;
}
if (entry.Color.HasValue)
{
return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}";
}
return entry.Text;
}
/// <summary>
/// Appends one or more output entries to the output list under lock,
/// updates <see cref="_lastEntryType"/> to the last entry's type, and renders.
/// </summary>
private void AppendOutputEntries(params OutputEntry[] entries)
{
lock (this._outputLock)
{
foreach (OutputEntry entry in entries)
{
this._outputItems.Add(entry);
}
if (entries.Length > 0)
{
this._lastEntryType = entries[^1].Type;
}
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
}
}
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Abstraction over the harness UI state. All callers (observers, command handlers,
/// the agent runner) interact with the UI exclusively through this interface, which
/// internally translates each operation into a <c>SetState</c> call on the underlying
/// reactive component.
/// </summary>
/// <remarks>
/// This interface is intentionally narrow: it does not expose blocking input methods.
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
/// objects returned from observers.
/// </remarks>
public interface IUXStateDriver
{
/// <summary>
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also
/// refreshes the rule colour and bottom-panel prompt to match the new mode.
/// </summary>
string? CurrentMode { get; set; }
/// <summary>
/// Echoes a submitted user input as a regular user-input entry in the output area.
/// </summary>
void WriteUserInputEcho(string text);
/// <summary>
/// Writes informational output as an output entry, without a trailing newline.
/// </summary>
Task WriteInfoAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes informational output as an output entry, followed by a newline.
/// </summary>
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes streaming text output from the agent. Successive calls accumulate into a
/// single streaming entry that is re-rendered by the text panel.
/// </summary>
Task WriteTextAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes a blank-line separator to visually close the streaming output section.
/// </summary>
Task EndStreamingOutputAsync();
/// <summary>
/// Shows a "(no text response from agent)" warning if no text was received
/// and no observer produced follow-up actions.
/// </summary>
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
/// <summary>
/// Switches the bottom panel to streaming mode and starts the spinner.
/// </summary>
void BeginStreaming();
/// <summary>
/// Stops the spinner without leaving streaming mode.
/// </summary>
void StopSpinner();
/// <summary>
/// Switches the bottom panel back to text-input mode and stops the spinner.
/// </summary>
void EndStreaming();
/// <summary>
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
/// </summary>
void BeginStreamingOutput();
/// <summary>
/// Sets the formatted usage text shown on the agent status bar.
/// </summary>
void SetUsageText(string usageText);
/// <summary>
/// Replaces the queued-message display with one entry per pending message.
/// </summary>
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
/// <summary>
/// Appends the supplied questions to the pending follow-up question queue in
/// component state. If the queue was empty, the bottom-panel display is
/// reconfigured to present the new head question.
/// </summary>
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
/// <summary>
/// Appends a message to the accumulated follow-up response list in component state.
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
/// the component when a question's continuation produces a response.
/// </summary>
void AddFollowUpResponse(ChatMessage response);
/// <summary>
/// Pops the head of the pending follow-up question queue. Reconfigures the
/// bottom-panel display for the new head, or restores the default text-input
/// mode if the queue is now empty.
/// </summary>
void AdvanceFollowUpQuestion();
/// <summary>
/// Returns the current accumulated follow-up responses and clears them in state.
/// Called by the runner immediately before invoking the next agent turn.
/// </summary>
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
/// <summary>
/// Signals that the application should shut down. Completes the shutdown task
/// on the owning component.
/// </summary>
void RequestShutdown();
}
@@ -18,36 +18,41 @@ public abstract class ConsoleObserver
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
/// </summary>
/// <param name="options">The run options to configure.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options)
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
{
}
/// <summary>
/// Called for each <see cref="AIContent"/> item in the response stream.
/// </summary>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="content">The content item from the stream.</param>
public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask;
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
/// <summary>
/// Called for each text update in the response stream.
/// </summary>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="text">The text from the update.</param>
public virtual Task OnTextAsync(HarnessUXContainer ux, string text) => Task.CompletedTask;
/// <summary>
/// Called after the response stream completes. Returns messages to include in the
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
/// </summary>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
/// <param name="options">The console options.</param>
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
HarnessUXContainer ux,
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
/// <summary>
/// Called after the response stream completes. Returns a heterogeneous list of
/// follow-up actions (questions to ask the user, and/or messages to add directly to
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays error content (❌) from the response stream.
/// </summary>
internal sealed class ErrorDisplayObserver : ConsoleObserver
public sealed class ErrorDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is ErrorContent errorContent)
{
@@ -2,51 +2,77 @@
using System.Text;
using System.Text.Json;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Planning observer that configures structured output, collects streamed text,
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
/// questions and approval prompts, and manages mode switching when the user approves a plan.
/// Planning observer that is mode-aware: in planning mode it configures structured
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
/// for live streaming display.
/// </summary>
internal sealed class PlanningOutputObserver : ConsoleObserver
public sealed class PlanningOutputObserver : ConsoleObserver
{
private readonly StringBuilder _textCollector = new();
private readonly AgentModeProvider _modeProvider;
private readonly string _planModeName;
private readonly string _executionModeName;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
/// <summary>
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
/// </summary>
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
public PlanningOutputObserver(AgentModeProvider modeProvider)
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._modeProvider = modeProvider;
this._planModeName = planModeName;
this._executionModeName = executionModeName;
this._modeColors = modeColors;
}
/// <inheritdoc/>
public override void ConfigureRunOptions(AgentRunOptions options)
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
}
/// <inheritdoc/>
public override Task OnTextAsync(HarnessUXContainer ux, string text)
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
{
// Collect text silently instead of displaying it.
this._textCollector.Append(text);
return Task.CompletedTask;
if (this.IsPlanningMode(ux.CurrentMode))
{
// Planning mode: collect text silently for JSON parsing after the stream.
this._textCollector.Append(text);
return Task.CompletedTask;
}
// Execution mode: stream text directly to the console.
return ux.WriteTextAsync(text);
}
/// <inheritdoc/>
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
HarnessUXContainer ux,
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options)
AgentSession session)
{
if (!this.IsPlanningMode(ux.CurrentMode))
{
// Execution mode: text was already streamed live; nothing to parse.
this._textCollector.Clear();
return null;
}
// Read collected text from our stream observation.
string collectedText = this._textCollector.ToString();
this._textCollector.Clear();
@@ -75,10 +101,9 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
return null;
}
// Render based on response type.
if (planningResponse.Type == PlanningResponseType.Clarification)
{
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse));
return BuildClarificationActions(planningResponse);
}
if (planningResponse.Type == PlanningResponseType.Approval)
@@ -90,67 +115,87 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
return null;
}
string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options);
if (response == "Approved")
{
this._modeProvider.SetMode(session, options.ExecutionModeName!);
await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
ModeColors.Get(options.ExecutionModeName, options.ModeColors));
}
return AsUserMessages(response);
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
return null;
}
private static IList<ChatMessage>? AsUserMessages(string? text) =>
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(HarnessUXContainer ux, PlanningResponse response)
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
{
var answers = new List<string>();
var actions = new List<FollowUpAction>(response.Questions.Count);
foreach (var question in response.Questions)
{
string? answer;
string prompt = question.Message;
async Task<ChatMessage?> Continuation(string answer, IUXStateDriver ux)
{
if (string.IsNullOrWhiteSpace(answer))
{
string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false);
return null;
}
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}");
}
if (question.Choices is { Count: > 0 })
{
answer = await ux.ReadSelectionAsync(
question.Message,
question.Choices);
actions.Add(new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: question.Choices,
AllowCustomText: true,
Continuation: Continuation));
}
else
{
answer = (await ux.ReadLineAsync(question.Message))?.Trim();
}
if (!string.IsNullOrWhiteSpace(answer))
{
answers.Add($"Q: {question.Message}\nA: {answer}");
actions.Add(new TextFollowUpQuestion(
Prompt: prompt,
Continuation: Continuation));
}
}
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
return actions;
}
private async Task<string> RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options)
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
{
var choices = new List<string>
{
"Approve and switch to execute mode",
};
const string ApproveOption = "Approve and switch to execute mode";
var choices = new List<string> { ApproveOption };
string selection = await ux.ReadSelectionAsync(question.Message, choices);
return new ChoiceFollowUpQuestion(
Prompt: question.Message,
Choices: choices,
AllowCustomText: true,
Continuation: async (selection, ux) =>
{
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
if (selection == choices[0])
{
return "Approved";
}
if (selection == ApproveOption)
{
this._modeProvider.SetMode(session, this._executionModeName);
await ux.WriteInfoLineAsync(
$"✅ Switched to {this._executionModeName} mode.",
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, "Approved");
}
// Custom freeform input — treat as suggested changes.
return selection;
// Custom freeform input — treat as suggested changes.
return new ChatMessage(ChatRole.User, selection);
});
}
/// <summary>
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
/// </summary>
private bool IsPlanningMode(string? currentMode) =>
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays reasoning content in dark magenta from the response stream.
/// </summary>
internal sealed class ReasoningDisplayObserver : ConsoleObserver
public sealed class ReasoningDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
@@ -1,15 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Streams agent text output directly to the console.
/// Used in normal (non-planning) mode.
/// </summary>
internal sealed class TextOutputObserver : ConsoleObserver
public sealed class TextOutputObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnTextAsync(HarnessUXContainer ux, string text)
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
{
await ux.WriteTextAsync(text);
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -7,86 +9,103 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
/// displays approval-needed notifications inline, and prompts the user for approval
/// decisions after the stream completes.
/// displays approval-needed notifications inline, and after the stream completes returns
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
/// response content.
/// </summary>
internal sealed class ToolApprovalObserver : ConsoleObserver
public sealed class ToolApprovalObserver : ConsoleObserver
{
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is ToolApprovalRequestContent approvalRequest)
{
this._approvalRequests.Add(approvalRequest);
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(fc)
? ToolCallFormatter.Format(this._formatters, fc)
: approvalRequest.ToolCall?.ToString() ?? "unknown";
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
}
}
/// <inheritdoc/>
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
HarnessUXContainer ux,
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options)
AgentSession session)
{
if (this._approvalRequests.Count == 0)
{
return null;
return Task.FromResult<IList<FollowUpAction>?>(null);
}
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
foreach (var request in this._approvalRequests)
{
actions.Add(this.BuildApprovalQuestion(request));
}
var messages = await PromptForApprovalsAsync(ux, this._approvalRequests);
this._approvalRequests.Clear();
return messages;
return Task.FromResult<IList<FollowUpAction>?>(actions);
}
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(HarnessUXContainer ux, List<ToolApprovalRequestContent> approvalRequests)
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
{
if (approvalRequests.Count == 0)
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(this._formatters, fc)
: request.ToolCall?.ToString() ?? "unknown";
var choices = new List<string>
{
return null;
}
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
var responses = new List<AIContent>();
foreach (var request in approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(fc)
: request.ToolCall?.ToString() ?? "unknown";
string prompt = $"🔐 Tool approval: {toolName}";
var choices = new List<string>
return new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: choices,
AllowCustomText: false,
Continuation: async (selection, ux) =>
{
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
string selection = await ux.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
responses.Add(response);
}
return [new ChatMessage(ChatRole.User, responses)];
return new ChatMessage(ChatRole.User, [response]);
});
}
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,14 +10,26 @@ namespace Harness.Shared.Console.Observers;
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
/// and <see cref="ToolCallContent"/> items in the response stream.
/// </summary>
internal sealed class ToolCallDisplayObserver : ConsoleObserver
public sealed class ToolCallDisplayObserver : ConsoleObserver
{
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is FunctionCallContent functionCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is ToolCallContent toolCall)
{
@@ -1,288 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
/// for console display.
/// </summary>
public static class ToolCallFormatter
{
/// <summary>
/// Returns a formatted string for the given tool call, with human-readable
/// details for known tools (todos, mode, sub-agents, web tools).
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A formatted string describing the tool call.</returns>
public static string Format(FunctionCallContent call)
{
string? detail = call.Name switch
{
// Todo tools
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
"TodoList_GetRemaining" => null,
"TodoList_GetAll" => null,
// Mode tools
"AgentMode_Set" => FormatStringArg(call, "mode"),
"AgentMode_Get" => null,
// Sub-agent tools
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_GetAllTasks" => null,
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
// File memory tools
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_ListFiles" => null,
"FileMemory_SearchFiles" => FormatSearchFiles(call),
// External tools
"web_search" => FormatStringArg(call, "query"),
"DownloadUri" => FormatStringArg(call, "uri"),
_ => FormatFallback(call),
};
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
private static string? FormatAddTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
{
return null;
}
var titles = new List<string>();
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
string? title = item.TryGetProperty("title", out JsonElement titleElement)
? titleElement.GetString()
: null;
if (!string.IsNullOrEmpty(title))
{
titles.Add(title);
}
}
}
if (titles.Count == 0)
{
return null;
}
var sb = new StringBuilder();
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
foreach (string title in titles)
{
sb.Append($"\n • {title}");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntList(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
return $"({verb} #{string.Join(", #", ids)})";
}
private static string? FormatSingleId(FunctionCallContent call, string paramName)
{
int? id = GetInt(call, paramName);
return id.HasValue ? $"(task #{id.Value})" : null;
}
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetString(call, "agentName");
string? description = GetString(call, "description");
if (agentName is null && description is null)
{
return null;
}
var sb = new StringBuilder("(");
if (agentName is not null)
{
sb.Append($"agent: {agentName}");
}
if (description is not null)
{
if (agentName is not null)
{
sb.Append(", ");
}
sb.Append($"\"{Truncate(description, 60)}\"");
}
sb.Append(')');
return sb.ToString();
}
private static string? FormatContinueTask(FunctionCallContent call)
{
int? taskId = GetInt(call, "taskId");
string? text = GetString(call, "text");
if (!taskId.HasValue)
{
return null;
}
return text is not null
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
: $"(task #{taskId.Value})";
}
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetString(call, "fileName");
string? description = GetString(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"({fileName})"
: $"({fileName}, with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetString(call, "regexPattern");
string? filePattern = GetString(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetString(call, paramName);
return value is not null ? $"({value})" : null;
}
private static string? FormatFallback(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
private static string? GetString(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
string s => s,
_ => value.ToString(),
};
}
private static int? GetInt(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
int i => i,
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
};
}
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
var result = new List<int>();
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in je.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
{
result.Add(item.GetInt32());
}
}
}
return result.Count > 0 ? result : null;
}
private static string Truncate(string text, int maxLength)
{
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays token usage statistics (📊) from the response stream.
/// </summary>
internal sealed class UsageDisplayObserver : ConsoleObserver
public sealed class UsageDisplayObserver : ConsoleObserver
{
private readonly int? _maxContextWindowTokens;
private readonly int? _maxOutputTokens;
@@ -24,7 +25,7 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
}
/// <inheritdoc/>
public override Task OnContentAsync(HarnessUXContainer ux, AIContent content)
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is UsageContent usage)
{
@@ -5,7 +5,7 @@ namespace Harness.Shared.Console;
/// <summary>
/// Represents the type of an output entry in the console conversation.
/// </summary>
public enum OutputEntryType
internal enum OutputEntryType
{
/// <summary>User input echo (e.g. "You: hello").</summary>
UserInput,
@@ -25,9 +25,10 @@ public enum OutputEntryType
/// <summary>
/// Represents a single output entry in the console conversation history.
/// These entries are rendered by the <see cref="HarnessAppComponent"/> via its render delegate.
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
/// the in-progress streaming entry and last-entry type for spacing decisions.
/// </summary>
/// <param name="Type">The type of output entry.</param>
/// <param name="Text">The text content of the entry.</param>
/// <param name="Color">Optional foreground color for rendering.</param>
public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
/// Displays a generic summary of the tool's arguments. This formatter should always be
/// placed last in the formatter list.
/// </summary>
public sealed class FallbackToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => true;
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
/// with tree-view corners for save operations.
/// </summary>
public sealed class FileMemoryToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_SearchFiles" => FormatSearchFiles(call),
_ => null,
};
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
string? description = GetStringArgumentValue(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"\n └─ {fileName}"
: $"\n └─ {fileName} (with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetStringArgumentValue(call, "regexPattern");
string? filePattern = GetStringArgumentValue(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
/// </summary>
public sealed class ModeToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"AgentMode_Set" => FormatStringArg(call, "mode"),
_ => null,
};
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
/// for task start, continue, wait, and result retrieval operations.
/// </summary>
public sealed class SubAgentToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
_ => null,
};
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetStringArgumentValue(call, "agentName");
string? description = GetStringArgumentValue(call, "description");
if (agentName is null && description is null)
{
return null;
}
var sb = new StringBuilder();
if (agentName is not null && description is not null)
{
sb.Append($"\n ├─ Agent: {agentName}");
sb.Append($"\n └─ \"{Truncate(description, 80)}\"");
}
else if (agentName is not null)
{
sb.Append($"\n └─ Agent: {agentName}");
}
else
{
sb.Append($"\n └─ \"{Truncate(description!, 80)}\"");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < ids.Count; i++)
{
string connector = i < ids.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {verb} #{ids[i]}");
}
return sb.ToString();
}
private static string? FormatSingleId(FunctionCallContent call, string paramName)
{
int? id = GetIntArgumentValue(call, paramName);
return id.HasValue ? $"(task #{id.Value})" : null;
}
private static string? FormatContinueTask(FunctionCallContent call)
{
int? taskId = GetIntArgumentValue(call, "taskId");
string? text = GetStringArgumentValue(call, "text");
if (!taskId.HasValue)
{
return null;
}
if (text is not null)
{
var sb = new StringBuilder();
sb.Append($"\n ├─ Task #{taskId.Value}");
sb.Append($"\n └─ \"{Truncate(text, 80)}\"");
return sb.ToString();
}
return $"\n └─ Task #{taskId.Value}";
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
/// and structured output for complete/remove operations.
/// </summary>
public sealed class TodoToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
private static string? FormatAddTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
{
return null;
}
var titles = new List<string>();
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
string? title = item.TryGetProperty("title", out JsonElement titleElement)
? titleElement.GetString()
: null;
if (!string.IsNullOrEmpty(title))
{
titles.Add(title);
}
}
}
if (titles.Count == 0)
{
return null;
}
var sb = new StringBuilder();
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
for (int i = 0; i < titles.Count; i++)
{
string connector = i < titles.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {titles[i]}");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < ids.Count; i++)
{
string connector = i < ids.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {verb} #{ids[i]}");
}
return sb.ToString();
}
}
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Base class for tool call formatters that produce human-readable display strings
/// for <see cref="FunctionCallContent"/> items shown in the console.
/// </summary>
public abstract class ToolCallFormatter
{
/// <summary>
/// Returns <see langword="true"/> if this formatter can handle the given function call.
/// </summary>
/// <param name="call">The function call content to check.</param>
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
public abstract bool CanFormat(FunctionCallContent call);
/// <summary>
/// Returns the detail portion of the formatted output for the given tool call,
/// or <see langword="null"/> if only the tool name should be displayed.
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
public abstract string? FormatDetail(FunctionCallContent call);
/// <summary>
/// Formats a tool call using the first matching formatter from the provided list.
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
/// or just <c>"{toolName}"</c> otherwise.
/// </summary>
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
{
foreach (var formatter in formatters)
{
if (formatter.CanFormat(call))
{
string? detail = formatter.FormatDetail(call);
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
}
return call.Name;
}
/// <summary>
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
/// is always last. Users can call this method and combine the result with their own formatters.
/// </summary>
/// <returns>A list of all built-in tool call formatters.</returns>
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
{
return
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new SubAgentToolFormatter(),
new FileMemoryToolFormatter(),
new WebSearchToolFormatter(),
new FallbackToolFormatter(),
];
}
/// <summary>
/// Extracts a string argument value from a function call.
/// </summary>
protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
string s => s,
_ => value.ToString(),
};
}
/// <summary>
/// Extracts an integer argument value from a function call.
/// </summary>
protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
int i => i,
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
};
}
/// <summary>
/// Extracts a list of integer argument values from a function call.
/// </summary>
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
var result = new List<int>();
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in je.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
{
result.Add(item.GetInt32());
}
}
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
/// </summary>
protected static string Truncate(string text, int maxLength)
{
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
}
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>web_search</c> tool calls, showing the search query.
/// </summary>
public sealed class WebSearchToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "web_search";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "query");
return value is not null ? $"({value})" : null;
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
/// </summary>
public sealed class DownloadUriToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "DownloadUri";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "uri");
return value is not null ? $"({value})" : null;
}
}
@@ -8,7 +8,8 @@
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// exit — End the session.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -16,6 +17,7 @@
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
@@ -158,13 +160,15 @@ AIAgent agent =
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
title: "Research Assistant",
userPrompt: "Enter a research topic to get started.",
new HarnessConsoleOptions
{
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
EnablePlanningUx = true,
PlanningModeName = "plan",
ExecutionModeName = "execute"
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens,
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
@@ -6,7 +6,7 @@
// equipped with Foundry's hosted web search tool.
//
// Special commands:
// exit — End the session.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -103,5 +103,4 @@ AIAgent parentAgent =
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
title: "Stock Price Researcher (SubAgents Demo)",
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -8,7 +8,7 @@
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
// exit — End the session.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -85,5 +85,4 @@ AIAgent agent =
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
agent,
title: "Data Processing Assistant",
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeFoundryToolboxMcp.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
#
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
#
# The toolbox is provisioned with TWO different tool types:
# 1. A Foundry built-in web_search tool
# 2. A Microsoft Learn MCP server (microsoft_docs)
# Both are surfaced through the same MCP-compatible toolbox endpoint.
#
# The workflow:
# 1. Accepts a documentation/web search query as input
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
# 3. Invokes the microsoft_docs_search MCP tool
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
# 5. Uses an agent to summarize and combine both result sets
#
# Example input:
# How do I use Azure OpenAI with my data?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
# Set the search query from user input.
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =System.LastMessage.Text
# List tools exposed by the Foundry toolbox MCP proxy.
- kind: InvokeMcpTool
id: list_toolbox_tools
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: tools/list
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
output:
autoSend: true
result: Local.ToolboxTools
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
- kind: InvokeMcpTool
id: search_docs_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.SearchResult
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
- kind: InvokeMcpTool
id: search_web_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
search_query: =Local.SearchQuery
output:
autoSend: true
result: Local.WebSearchResult
# Use the agent to summarize what happened and answer from the toolbox result.
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
output:
autoSend: true
messages: Local.Summary
@@ -0,0 +1,218 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
/// <summary>
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
/// </summary>
/// <remarks>
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
/// and has a Foundry agent summarize the results.
/// </remarks>
internal sealed class Program
{
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
private const string DefaultToolboxApiVersion = "v1";
private const string DefaultDocsServerLabel = "microsoft_docs";
private const string DefaultWebSearchToolName = "web_search";
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
// Ensure sample toolbox and agent exist in Foundry
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
IConfiguration workflowConfiguration = new ConfigurationBuilder()
.AddConfiguration(configuration)
.AddInMemoryCollection(new Dictionary<string, string?>
{
[ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl,
[DocsServerLabelSetting] = docsServerLabel,
[WebSearchToolNameSetting] = webSearchToolName,
})
.Build();
await CreateAgentAsync(foundryEndpoint, configuration, credential);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
ConcurrentBag<HttpClient> createdHttpClients = [];
DefaultMcpToolHandler mcpToolHandler = new(
httpClientProvider: async (serverUrl, _) =>
{
await Task.CompletedTask.ConfigureAwait(false);
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
{
return null;
}
FoundryToolboxBearerTokenHandler handler = new(credential)
{
InnerHandler = new HttpClientHandler()
};
HttpClient httpClient = new(handler);
createdHttpClients.Add(httpClient);
return httpClient;
});
try
{
// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
{
Configuration = workflowConfiguration,
McpToolHandler = mcpToolHandler
};
// Execute the workflow
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
finally
{
// Clean up connections and dispose created HttpClients
await mcpToolHandler.DisposeAsync();
foreach (HttpClient httpClient in createdHttpClients)
{
httpClient.Dispose();
}
}
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
await aiProjectClient.CreateAgentAsync(
agentName: "FoundryToolboxMcpAgent",
agentDefinition: DefineToolboxAgent(configuration),
agentDescription: "Summarizes Foundry toolbox MCP tool results");
}
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
{
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
{
Instructions =
"""
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
Be concise.
"""
};
}
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
{
AgentAdministrationClientOptions options = new();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
try
{
await toolboxClient.DeleteToolboxAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Toolbox does not exist.
}
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: serverLabel,
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [webTool, mcpTool],
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
}
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
{
private static readonly TokenRequestContext s_tokenContext =
new(["https://ai.azure.com/.default"]);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
private const string FeatureHeader = "Foundry-Features";
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -101,10 +101,6 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -49,10 +49,6 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
@@ -28,10 +28,6 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -152,10 +148,6 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
app.MapOpenApi();
@@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI;
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
/// <list type="number">
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
/// </list>
@@ -110,6 +111,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
return chatClient
.AsBuilder()
.UseFunctionInvocation()
.UseMessageInjection()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(compactionProvider)
.BuildAIAgent(new ChatClientAgentOptions
@@ -27,7 +27,6 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -28,23 +28,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
/// dimension, so when a persistent store is registered any caller who knows or
/// guesses another caller's <c>contextId</c> can resume that other caller's
/// persisted thread. Hosts that serve more than one user must compose a principal
/// dimension into the lookup key — typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
/// conversation identifier, which is appropriate for first-run / single-user /
/// prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// </remarks>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
@@ -63,13 +46,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -89,13 +65,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -114,13 +83,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -152,13 +114,6 @@ public static class A2AServerServiceCollectionExtensions
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
/// <remarks>
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
/// identifier, not an authorization token; multi-user hosts must compose a
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
/// <see cref="SessionIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -185,17 +140,9 @@ public static class A2AServerServiceCollectionExtensions
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
}
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore);
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
agentHandler = new A2AAgentHandler(hostAgent, runMode);
}
@@ -73,26 +73,6 @@ public static class AGUIEndpointRouteBuilderExtensions
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
/// </para>
/// <para>
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
/// principal/owner dimension, so when a persistent store is registered any caller
/// who knows or guesses another caller's <c>ThreadId</c> can resume that other
/// caller's persisted thread. Hosts that serve more than one user must compose a
/// principal dimension into the lookup key. The recommended way is to wrap the
/// keyed <see cref="AgentSessionStore"/> in
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
/// isolation provider is registered, behavior is unchanged — the bare
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
/// for first-run / single-user / prototyping scenarios but unsafe for
/// multi-user hosts.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
@@ -1,78 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// <para>
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
/// </para>
/// <para>
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
/// to provide access to the current <see cref="HttpContext"/>.
/// </para>
/// </remarks>
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly string _claimType;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
/// </param>
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
/// <exception cref="ArgumentException">
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// </exception>
public ClaimsIdentitySessionIsolationKeyProvider(
IHttpContextAccessor? httpContextAccessor,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
this._httpContextAccessor = httpContextAccessor;
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
}
/// <summary>
/// Extracts the session isolation key from the current user's claims.
/// </summary>
/// <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 the value of the
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
/// is not present or the HTTP context is unavailable.
/// </returns>
/// <remarks>
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
/// of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
Claim? claim = this._httpContextAccessor?
.HttpContext?
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
return new ValueTask<string?>(claim?.Value);
}
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Security.Claims;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderOptions
{
/// <summary>
/// Gets or sets the claim type to extract from the user's identity for session isolation.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
/// the user's name or unique identifier claim.
/// </para>
/// <para>
/// Common alternatives include:
/// <list type="bullet">
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
/// <item><description>Custom claim types specific to your authentication provider</description></item>
/// </list>
/// </para>
/// </remarks>
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
}
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn)</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
</PropertyGroup>
</Project>
@@ -1,42 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate session isolation keys.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
/// </remarks>
public static IServiceCollection UseClaimsBasedSessionIsolation(
this IServiceCollection services,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
{
options ??= new();
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Scoped);
services.Add(descriptor);
return services;
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
{
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
}
}
}
@@ -1,9 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -11,39 +9,9 @@ namespace Microsoft.Agents.AI.Hosting;
/// Defines the contract for storing and retrieving agent conversation threads.
/// </summary>
/// <remarks>
/// <para>
/// Implementations of this interface enable persistent storage of conversation threads,
/// allowing conversations to be resumed across HTTP requests, application restarts,
/// or different service instances in hosted scenarios.
/// </para>
/// <para>
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
/// dimension. Hosts that serve more than one user from the same registered store must
/// therefore compose a principal dimension into the lookup key, otherwise any caller
/// who knows or guesses another caller's <c>conversationId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>conversationId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
/// store behaves as a single-namespace persistence layer — appropriate for
/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
/// </para>
/// <para>
/// <strong>Implementer guidance.</strong> Implementations should treat
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
/// or character-set constraints on it, and do not assume it round-trips to the value
/// the caller originally supplied (decorators such as
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
/// Be aware that any logging, telemetry, or audit sink that surfaces
/// <c>conversationId</c> will also surface the isolation prefix when a
/// scoping decorator is in the chain.
/// </para>
/// </remarks>
public abstract class AgentSessionStore
{
@@ -75,35 +43,4 @@ public abstract class AgentSessionStore
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
/// to verify that specific store implementations are present.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
/// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
/// to verify that specific store implementations are present.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}
@@ -1,81 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
/// instance while allowing for extensibility and customization.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
/// underlying store.
/// </para>
/// <para>
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
/// interface.
/// </para>
/// </remarks>
public abstract class DelegatingAgentSessionStore : AgentSessionStore
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
/// store.
/// </summary>
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
/// derived classes will be forwarded to this store.
/// </remarks>
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
{
this.InnerStore = Throw.IfNull(innerStore);
}
/// <summary>
/// Gets the inner session store instance that receives delegated operations.
/// </summary>
/// <value>
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
/// </value>
/// <remarks>
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
/// or to forward operations with additional processing.
/// </remarks>
protected AgentSessionStore InnerStore { get; }
/// <inheritdoc/>
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
/// <inheritdoc/>
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
/// <inheritdoc/>
/// <remarks>
/// This implementation first checks if this instance satisfies the service request.
/// If not, it chains the request to the inner store, allowing services to be retrieved
/// from any store in the delegation chain.
/// </remarks>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
// First, check if this instance satisfies the request
object? service = base.GetService(serviceType, serviceKey);
if (service is not null)
{
return service;
}
// Chain to the inner store
return this.InnerStore.GetService(serviceType, serviceKey);
}
}
@@ -3,7 +3,6 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -17,11 +16,12 @@ public static class HostedAgentBuilderExtensions
/// Configures the host agent builder to use an in-memory session store for agent session management.
/// </summary>
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
{
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
return builder;
}
/// <summary>
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
@@ -29,11 +29,12 @@ public static class HostedAgentBuilderExtensions
/// </summary>
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
{
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
return builder;
}
/// <summary>
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
@@ -43,36 +44,16 @@ public static class HostedAgentBuilderExtensions
/// name.</param>
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
return createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
// Best efforts options getting
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
if (options is null)
{
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
options = optionsProvider?.Value;
}
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
}
return store;
}, lifetime);
return builder;
}
@@ -1,109 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
/// </summary>
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="innerStore"/> is <see langword="null"/>.
/// </exception>
public IsolationKeyScopedAgentSessionStore(
AgentSessionStore innerStore,
SessionIsolationKeyProvider? keyProvider,
IsolationKeyScopedAgentSessionStoreOptions? options = null)
: base(innerStore)
{
this._keyProvider = keyProvider;
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
this._strict = options.Strict;
}
/// <summary>
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The provider returned <see langword="null"/> and strict mode is enabled.
/// </exception>
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
}
return key;
}
/// <summary>
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
/// </summary>
/// <param name="key">The raw isolation key.</param>
/// <returns>The escaped isolation key.</returns>
/// <remarks>
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
/// </remarks>
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
/// <summary>
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
/// </summary>
/// <param name="bareConversationId">The original conversation ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
/// if no isolation key is available and non-strict mode is enabled.
/// </returns>
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
{
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
if (key == null)
{
return bareConversationId;
}
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
}
/// <inheritdoc />
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
}
}
@@ -1,25 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
/// </summary>
public class IsolationKeyScopedAgentSessionStoreOptions
{
/// <summary>
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
/// </summary>
/// <remarks>
/// <para>
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
/// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
/// or mixed environments where not all requests have isolation keys.
/// </para>
/// </remarks>
public bool Strict { get; set; } = true;
}
@@ -24,20 +24,6 @@ namespace Microsoft.Agents.AI.Hosting;
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
/// such as Redis, SQL Server, or Azure Cosmos DB.
/// </para>
/// <para>
/// <strong>Multi-user warning.</strong> This store keys threads by
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
/// the conversation identifier originates from the wire (for example, an AG-UI
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
/// scoped per principal. See the trust-model remarks on
/// <see cref="AgentSessionStore"/> for the full background.
/// </para>
/// </remarks>
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
@@ -1,39 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
/// </summary>
/// <remarks>
/// <para>
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
/// implement the key resolution logic appropriate to their hosting environment.
/// </para>
/// <para>
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
/// to unscoped storage based on its configuration.
/// </para>
/// </remarks>
public abstract class SessionIsolationKeyProvider
{
/// <summary>
/// Asynchronously retrieves the session isolation key for the current request or execution context.
/// </summary>
/// <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 the isolation key string,
/// or <see langword="null"/> if no key is available in the current context.
/// </returns>
/// <remarks>
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
/// the caller to decide on strict vs. pass-through behavior.
/// </remarks>
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
}
@@ -3,12 +3,15 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
/// <summary>
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
/// to the MCP protocol <c>tools/list</c> discovery operation.
/// </summary>
public const string ListToolsToolName = "tools/list";
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
@@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
if (IsListToolsToolName(toolName))
{
ThrowIfListToolsArgumentsSpecified(arguments);
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
}
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
? null
@@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return resultContent;
}
internal static bool IsListToolsToolName(string toolName) =>
string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable<Tool> tools)
{
Throw.IfNull(tools);
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString())
{
Outputs = []
};
resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools)));
return resultContent;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
@@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return hashCode.ToString(CultureInfo.InvariantCulture);
}
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
{
if (arguments is { Count: > 0 })
{
throw new ArgumentException(
$"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.",
nameof(arguments));
}
}
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
{
// Ensure Outputs list is initialized
@@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
{
return block.Resource switch
{
TextResourceContents text => new TextContent(text.Text),
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
@@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
}
private static string SerializeToolsList(IEnumerable<Tool> tools)
{
using MemoryStream stream = new();
using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions))
{
writer.WriteStartObject();
writer.WriteStartArray("tools");
foreach (Tool tool in tools)
{
writer.WriteStartObject();
writer.WriteString("name", tool.Name);
writer.WriteString("description", tool.Description);
writer.WritePropertyName("inputSchema");
tool.InputSchema.WriteTo(writer);
writer.WritePropertyName("outputSchema");
if (tool.OutputSchema is JsonElement outputSchema)
{
outputSchema.WriteTo(writer);
}
else
{
writer.WriteNullValue();
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
}
}
@@ -54,6 +54,8 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private string? _name;
private string? _description;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -97,6 +99,20 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public TBuilder WithName(string name)
{
this._name = name;
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public TBuilder WithDescription(string description)
{
this._description = description;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
@@ -330,7 +346,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Build the workflow.
if (!string.IsNullOrWhiteSpace(this._name))
{
builder.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
builder.WithDescription(this._description);
}
return builder.WithOutputFrom(end).Build();
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
@@ -140,7 +141,15 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
public Workflow Build()
{
if (this._team.Count == 0)
{
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
}
return this.ReduceToWorkflowBuilder().Build();
}
private TaskLimits Limits => new(
MaxRoundCount: this._maxRounds,
@@ -101,6 +101,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
return base.ConfigureProtocol(protocolBuilder)
.SendsMessage<ChatMessage>()
.SendsMessage<ResetChatSignal>()
.YieldsOutput<List<ChatMessage>>()
.ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
@@ -109,7 +110,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
out this._planReviewPort);
}
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext, bool replanAfterStall = false)
{
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
if (progressLedger?.IsStarted is not true)
@@ -117,7 +118,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
progressLedger = null;
}
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, replanAfterStall);
return this._planReviewPort!.PostRequestAsync(request);
}
@@ -146,7 +147,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (this._taskContext.IsTerminated)
{
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (response.IsApproved)
@@ -161,7 +162,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
}
}
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken, bool replanAfterStall = false)
{
bool isReplan = taskContext.TaskLedger != null;
@@ -177,7 +178,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
if (requirePlanSignoff)
{
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
await this.SubmitPlanReviewRequestAsync(taskContext, context, replanAfterStall).ConfigureAwait(false);
}
else
{
@@ -187,9 +188,22 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
// First Turn: Initialize the task context and send the initial messages to the planner agent
this._taskContext ??= new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
if (this._taskContext?.IsTerminated == true)
{
throw new InvalidOperationException("This Magentic orchestration has already terminated. To process new messages, create a new workflow instance.");
}
if (this._taskContext == null)
{
// First Turn: Initialize the task context and create the initial plan
this._taskContext = new(messages, team, limits, emitEvents, []);
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
else
{
// Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan)
await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
}
}
private ChatMessage? _fullTaskLedgerMessage;
@@ -288,10 +302,11 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
{
bool wasStalled = taskContext.IsStalled;
taskContext.Reset();
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false);
}
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
@@ -58,7 +58,7 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
public bool IsTerminated { get; internal set; }
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
public bool IsStalled => this.TaskCounters.StallCount > this.TaskLimits.MaxStallCount;
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
{
@@ -36,9 +36,13 @@ public sealed class SwitchBuilder
Throw.IfNull(executors);
HashSet<int> indicies = [];
int executorIndex = 0;
foreach (ExecutorBinding executor in executors)
{
// Explicit name: null element inside the collection argument.
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
index = this._executors.Count;
@@ -64,8 +68,13 @@ public sealed class SwitchBuilder
{
Throw.IfNull(executors);
int executorIndex = 0;
foreach (ExecutorBinding executor in executors)
{
// Explicit name: null element inside the collection argument.
Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]");
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
index = this._executors.Count;
@@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions
/// <param name="target">The target executor to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardMessage<TMessage>(source, [target], condition: null);
{
Throw.IfNull(target, nameof(target));
return builder.ForwardMessage<TMessage>(source, [target], condition: null);
}
/// <summary>
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
@@ -52,6 +56,8 @@ public static class WorkflowBuilderExtensions
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<TMessage, bool>? condition = null)
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(IsAllowedTypeAndMatchingCondition)!;
@@ -62,14 +68,16 @@ public static class WorkflowBuilderExtensions
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, targets.First(), predicate);
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
// The reason we can check for "not null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message));
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
}
/// <summary>
@@ -81,7 +89,11 @@ public static class WorkflowBuilderExtensions
/// <param name="target">The target executor to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardExcept<TMessage>(source, [target]);
{
Throw.IfNull(target, nameof(target));
return builder.ForwardExcept<TMessage>(source, [target]);
}
/// <summary>
/// Adds edges from the specified source to the provided executors, excluding messages of a specified type.
@@ -93,6 +105,8 @@ public static class WorkflowBuilderExtensions
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
@@ -103,14 +117,16 @@ public static class WorkflowBuilderExtensions
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, targets.First(), predicate);
return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget)));
// The reason we can check for "null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
static bool IsAllowedType(object? message) => message is null;
ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets));
}
/// <summary>
@@ -129,6 +145,7 @@ public static class WorkflowBuilderExtensions
{
Throw.IfNull(builder);
Throw.IfNull(source);
Throw.IfNull(executors);
HashSet<string> seenExecutors = [source.Id];
@@ -1,251 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderTests
{
private const string TestUserId = "test-user-id";
private const string CustomClaimType = "custom-claim-type";
private const string CustomClaimValue = "custom-claim-value";
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
/// </summary>
public ClaimsIdentitySessionIsolationKeyProviderTests()
{
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
}
#region Constructor Tests
/// <summary>
/// Verify that constructor uses default options when options is null.
/// </summary>
[Fact]
public void UsesDefaultOptionsWhenNull()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
Assert.NotNull(provider);
}
/// <summary>
/// Verify that constructor accepts null IHttpContextAccessor.
/// </summary>
[Fact]
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
Assert.NotNull(provider);
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is null.
/// </summary>
[Fact]
public void RequiresClaimType_NotNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is empty.
/// </summary>
[Fact]
public void RequiresClaimType_NotEmpty()
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
}
/// <summary>
/// Verify that constructor throws ArgumentException when claimType is whitespace.
/// </summary>
[Fact]
public void RequiresClaimType_NotWhitespace()
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
}
#endregion
#region GetSessionIsolationKeyAsync Tests
/// <summary>
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(TestUserId, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(CustomClaimValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
{
// Arrange
this.SetupHttpContextWithClaim("other-claim", "value");
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify behavior when HttpContextAccessor returns null HttpContext.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
{
// Arrange
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify behavior when HttpContextAccessor itself is null.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
{
// Arrange
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
{
// Arrange
const string FirstValue = "first-value";
const string SecondValue = "second-value";
var claims = new[]
{
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
};
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
{
User = principal
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(FirstValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(string.Empty, result);
}
#endregion
#region Helper Methods
private void SetupHttpContextWithClaim(string claimType, string claimValue)
{
var claims = new[] { new Claim(claimType, claimValue) };
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
{
User = principal
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
}
#endregion
}
@@ -1,400 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
/// </summary>
public class DelegatingAgentSessionStoreTests
{
private readonly Mock<AgentSessionStore> _innerStoreMock;
private readonly Mock<AIAgent> _agentMock;
private readonly TestDelegatingAgentSessionStore _delegatingStore;
private readonly AgentSession _testSession;
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
/// </summary>
public DelegatingAgentSessionStoreTests()
{
this._innerStoreMock = new Mock<AgentSessionStore>();
this._agentMock = new Mock<AIAgent>();
this._testSession = new TestAgentSession();
// Setup inner store mock
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(this._testSession);
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
}
#region Constructor Tests
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerStore is null.
/// </summary>
[Fact]
public void RequiresInnerStore() =>
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
/// <summary>
/// Verify that constructor sets the inner store correctly.
/// </summary>
[Fact]
public void Constructor_WithValidInnerStore_SetsInnerStore()
{
// Act
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
// Assert
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
}
#endregion
#region Method Delegation Tests
/// <summary>
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
/// </summary>
[Fact]
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedCancellationToken = new CancellationToken();
this._innerStoreMock
.Setup(x => x.GetSessionAsync(
It.Is<AIAgent>(a => a == this._agentMock.Object),
It.Is<string>(c => c == ExpectedConversationId),
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
.ReturnsAsync(this._testSession);
// Act
var session = await this._delegatingStore.GetSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedCancellationToken);
// Assert
Assert.Same(this._testSession, session);
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedCancellationToken),
Times.Once);
}
/// <summary>
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
/// </summary>
[Fact]
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedCancellationToken = new CancellationToken();
var expectedSession = new TestAgentSession();
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(
It.Is<AIAgent>(a => a == this._agentMock.Object),
It.Is<string>(c => c == ExpectedConversationId),
It.Is<AgentSession>(s => s == expectedSession),
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
.Returns(ValueTask.CompletedTask);
// Act
await this._delegatingStore.SaveSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedSession,
expectedCancellationToken);
// Assert
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
ExpectedConversationId,
expectedSession,
expectedCancellationToken),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync awaits the inner store's result before returning.
/// </summary>
[Fact]
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
var innerStoreMock = new Mock<AgentSessionStore>();
innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
// Act
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
// Assert
Assert.False(resultTask.IsCompleted);
taskCompletionSource.SetResult(this._testSession);
Assert.True(resultTask.IsCompleted);
Assert.Same(this._testSession, await resultTask);
}
/// <summary>
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
/// </summary>
[Fact]
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
{
// Arrange
const string ExpectedConversationId = "test-conversation-id";
var expectedSession = new TestAgentSession();
var taskCompletionSource = new TaskCompletionSource();
var innerStoreMock = new Mock<AgentSessionStore>();
innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask(taskCompletionSource.Task));
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
// Act
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
// Assert
Assert.False(resultTask.IsCompleted);
taskCompletionSource.SetResult();
Assert.True(resultTask.IsCompleted);
await resultTask;
}
#endregion
#region GetService Tests
/// <summary>
/// Verify that GetService returns itself when requesting the exact type.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForExactType()
{
// Act
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService returns itself when requesting a base type.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForBaseType()
{
// Act
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService returns itself when requesting AgentSessionStore.
/// </summary>
[Fact]
public void GetServiceReturnsItselfForAgentSessionStoreType()
{
// Act
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
/// </summary>
[Fact]
public void GetServiceChainsToInnerStore()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService chains through multiple delegation layers.
/// </summary>
[Fact]
public void GetServiceChainsThoughMultipleDelegationLayers()
{
// Arrange - create a three-layer chain: outer -> middle -> inner
var innerStore = new ConcreteAgentSessionStore();
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
// Act - request the innermost store type
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService can find a store in the middle of the delegation chain.
/// </summary>
[Fact]
public void GetServiceFindsMiddleStoreInChain()
{
// Arrange - create a three-layer chain: outer -> middle -> inner
var innerStore = new ConcreteAgentSessionStore();
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
// Act - request the middle store type
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
// Assert
Assert.Same(middleStore, result);
}
/// <summary>
/// Verify that GetService returns null when the requested type is not found in the chain.
/// </summary>
[Fact]
public void GetServiceReturnsNullWhenTypeNotFound()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService returns null when a service key is provided but not matched.
/// </summary>
[Fact]
public void GetServiceReturnsNullWhenServiceKeyProvided()
{
// Act
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService throws ArgumentNullException when serviceType is null.
/// </summary>
[Fact]
public void GetServiceThrowsWhenServiceTypeIsNull() =>
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
/// <summary>
/// Verify that GetService generic method works correctly.
/// </summary>
[Fact]
public void GetServiceGenericReturnsItself()
{
// Act
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
// Assert
Assert.Same(this._delegatingStore, result);
}
/// <summary>
/// Verify that GetService generic method chains to inner store.
/// </summary>
[Fact]
public void GetServiceGenericChainsToInnerStore()
{
// Arrange
var innerStore = new ConcreteAgentSessionStore();
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
// Act
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
// Assert
Assert.Same(innerStore, result);
}
/// <summary>
/// Verify that GetService generic method returns null when type not found.
/// </summary>
[Fact]
public void GetServiceGenericReturnsNullWhenTypeNotFound()
{
// Act
var result = this._delegatingStore.GetService<string>();
// Assert
Assert.Null(result);
}
#endregion
#region Test Implementation
/// <summary>
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
/// </summary>
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
{
public new AgentSessionStore InnerStore => base.InnerStore;
}
/// <summary>
/// Another delegating store implementation for testing multi-layer chains.
/// </summary>
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
/// <summary>
/// Concrete (non-delegating) session store for testing GetService chaining.
/// </summary>
private sealed class ConcreteAgentSessionStore : AgentSessionStore
{
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class TestAgentSession : AgentSession;
#endregion
}
@@ -103,6 +103,30 @@ public class HostApplicationBuilderWorkflowExtensionsTests
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
}
/// <summary>
/// Verifies that a handoff workflow can be named from the DI workflow key.
/// </summary>
[Fact]
public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow()
{
var builder = new HostApplicationBuilder();
const string WorkflowName = "handoffWorkflow";
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("handoffAgent");
#pragma warning disable MAAIW001 // This test covers hosting handoff workflows.
builder.AddWorkflow(WorkflowName, (sp, key) =>
AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object)
.WithName(key)
.Build());
#pragma warning restore MAAIW001
var workflow = builder.Build().Services.GetRequiredKeyedService<Workflow>(WorkflowName);
Assert.Equal(WorkflowName, workflow.Name);
}
/// <summary>
/// Verifies that AddWorkflow handles empty strings for name.
/// </summary>
@@ -1,430 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
/// </summary>
public class IsolationKeyScopedAgentSessionStoreTests
{
private const string TestIsolationKey = "test-key";
private const string TestConversationId = "test-conversation-id";
private readonly Mock<AgentSessionStore> _innerStoreMock;
private readonly Mock<AIAgent> _agentMock;
private readonly AgentSession _testSession;
/// <summary>
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
/// </summary>
public IsolationKeyScopedAgentSessionStoreTests()
{
this._innerStoreMock = new Mock<AgentSessionStore>();
this._agentMock = new Mock<AIAgent>();
this._testSession = new TestAgentSession();
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(this._testSession);
this._innerStoreMock
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
}
#region Constructor Tests
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerStore is null.
/// </summary>
[Fact]
public void RequiresInnerStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () =>
new IsolationKeyScopedAgentSessionStore(null!, provider));
}
/// <summary>
/// Verify that constructor uses default options when options is null.
/// </summary>
[Fact]
public void UsesDefaultOptionsWhenNull()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
// Act & Assert - should not throw
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
Assert.NotNull(store);
}
#endregion
#region GetSessionAsync Tests
/// <summary>
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
/// </summary>
[Fact]
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"{TestIsolationKey}::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
/// </summary>
[Fact]
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
/// </summary>
[Fact]
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
// Act - should not throw
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - conversation ID should be passed through unmodified
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
TestConversationId,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that GetSessionAsync returns the session from the inner store.
/// </summary>
[Fact]
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
Assert.Same(this._testSession, result);
}
#endregion
#region SaveSessionAsync Tests
/// <summary>
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
/// </summary>
[Fact]
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
var sessionToSave = new TestAgentSession();
// Act
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
// Assert
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
$"{TestIsolationKey}::{TestConversationId}",
sessionToSave,
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
/// </summary>
[Fact]
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
var sessionToSave = new TestAgentSession();
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
Assert.Contains("Session isolation key is required", exception.Message);
}
/// <summary>
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
/// </summary>
[Fact]
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
var sessionToSave = new TestAgentSession();
// Act - should not throw
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
// Assert - conversation ID should be passed through unmodified
this._innerStoreMock.Verify(
x => x.SaveSessionAsync(
this._agentMock.Object,
TestConversationId,
sessionToSave,
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Escaping Tests
/// <summary>
/// Verify that colons in the isolation key are escaped.
/// </summary>
[Fact]
public async Task EscapesColonsInIsolationKeyAsync()
{
// Arrange
const string KeyWithColon = "key:with:colons";
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - colons should be escaped as \:
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"key\\:with\\:colons::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that backslashes in the isolation key are escaped.
/// </summary>
[Fact]
public async Task EscapesBackslashesInIsolationKeyAsync()
{
// Arrange
const string KeyWithBackslash = @"domain\key";
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - backslashes should be escaped as \\
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"domain\\\\key::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
/// </summary>
[Fact]
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
{
// Arrange
const string KeyWithBoth = @"domain\key:role";
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert - backslashes escaped first, then colons
this._innerStoreMock.Verify(
x => x.GetSessionAsync(
this._agentMock.Object,
$"domain\\\\key\\:role::{TestConversationId}",
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Isolation Tests
/// <summary>
/// Verify that different isolation keys result in different scoped conversation IDs.
/// </summary>
[Fact]
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
{
// Arrange
const string Key1 = "key-1";
const string Key2 = "key-2";
string? capturedConversationId1 = null;
string? capturedConversationId2 = null;
this._innerStoreMock
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
{
if (capturedConversationId1 == null)
{
capturedConversationId1 = conversationId;
}
else
{
capturedConversationId2 = conversationId;
}
})
.ReturnsAsync(this._testSession);
// Act - Key 1
var provider1 = new TestSessionIsolationKeyProvider(Key1);
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Act - Key 2
var provider2 = new TestSessionIsolationKeyProvider(Key2);
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
// Assert
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
}
#endregion
#region GetService Tests
/// <summary>
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
/// </summary>
[Fact]
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
// Assert
Assert.Same(store, result);
}
/// <summary>
/// Verify that GetService chains through to find inner store types.
/// </summary>
[Fact]
public void GetServiceChainsToInnerStore()
{
// Arrange
var concreteInnerStore = new ConcreteAgentSessionStore();
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
// Act
var result = store.GetService<ConcreteAgentSessionStore>();
// Assert
Assert.Same(concreteInnerStore, result);
}
#endregion
#region Helper Classes
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
}
private sealed class TestAgentSession : AgentSession;
/// <summary>
/// Concrete (non-delegating) session store for testing GetService chaining.
/// </summary>
private sealed class ConcreteAgentSessionStore : AgentSessionStore
{
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
#endregion
}
@@ -6,7 +6,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
</ItemGroup>
</Project>
@@ -1,95 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
/// </summary>
public class SessionIsolationKeyProviderTests
{
/// <summary>
/// Verify that a concrete provider can return a non-null isolation key.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
{
// Arrange
const string ExpectedKey = "test-key";
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(ExpectedKey, result);
}
/// <summary>
/// Verify that a concrete provider can return null when no key is available.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that cancellation token is passed through to the provider implementation.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
{
// Arrange
var provider = new TestCancellableSessionIsolationKeyProvider();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
}
#region Test Implementations
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
}
/// <summary>
/// Test implementation that respects cancellation tokens.
/// </summary>
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
{
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
await Task.Delay(1000, cancellationToken);
return "key";
}
}
#endregion
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests
#endregion
#region Reserved Tools/List Tests
[Fact]
public void IsListToolsToolName_WithReservedName_ShouldReturnTrue()
{
// Act
bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName);
// Assert
result.Should().BeTrue();
}
[Fact]
public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse()
{
// Act
bool result = DefaultMcpToolHandler.IsListToolsToolName("search");
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync()
{
// Arrange
DefaultMcpToolHandler handler = new();
try
{
// Act
Func<Task> act = async () => await handler.InvokeToolAsync(
serverUrl: "http://localhost:12345/mcp",
serverLabel: "test",
toolName: DefaultMcpToolHandler.ListToolsToolName,
arguments: new Dictionary<string, object?> { ["ignored"] = true },
headers: null,
connectionName: null);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.WithMessage("*does not accept tool arguments*");
}
finally
{
await handler.DisposeAsync();
}
}
[Fact]
public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync()
{
// Arrange
JsonElement inputSchema = JsonSerializer.Deserialize<JsonElement>(
"""
{
"type": "object",
"properties": {
"query": {
"type": "string"
}
},
"required": [ "query" ]
}
""");
Tool tool = new()
{
Name = "search",
Description = "Searches documentation.",
InputSchema = inputSchema
};
// Act
McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]);
// Assert
TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType<TextContent>().Subject;
using JsonDocument document = JsonDocument.Parse(text.Text);
JsonElement listedTool = document.RootElement.GetProperty("tools")[0];
listedTool.GetProperty("name").GetString().Should().Be("search");
listedTool.GetProperty("description").GetString().Should().Be("Searches documentation.");
listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string");
}
#endregion
#region Interface Implementation Tests
[Fact]
@@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests
dataContent.MediaType.Should().Be("audio/*");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent()
{
// Arrange
EmbeddedResourceBlock block = new()
{
Resource = new TextResourceContents
{
Text = "embedded text payload",
Uri = "resource://example",
MimeType = "text/plain",
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("embedded text payload");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
{
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = "application/zip",
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/zip");
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
{
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = null!,
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/octet-stream");
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
}
#endregion
}
@@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
VerifyInvocationEvent(events);
}
[Fact]
public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ListToolsToolName = "tools/list";
string? capturedToolName = null;
InvokeMcpTool model = this.CreateModel(
displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync),
serverUrl: TestServerUrl,
toolName: ListToolsToolName);
Mock<IMcpToolHandler> mockProvider = new();
mockProvider.Setup(provider => provider.InvokeToolAsync(
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<string>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<IDictionary<string, string>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
.ReturnsAsync(new McpServerToolResultContent("list-tools-call-id")
{
Outputs = [new TextContent("{\"tools\":[]}")]
});
MockAgentProvider mockAgentProvider = new();
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
Assert.Equal(ListToolsToolName, capturedToolName);
}
[Fact]
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
{
@@ -86,6 +86,23 @@ public class HandoffOrchestrationTests
target.Reason.Should().Be("instructions");
}
[Fact]
public void BuildHandoffs_WithNameAndDescription_SetsWorkflowMetadata()
{
const string WorkflowName = "handoff-workflow";
const string WorkflowDescription = "A handoff workflow";
DoubleEchoAgent agent = new("agent");
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent)
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Equal(WorkflowDescription, workflow.Description);
}
[Fact]
public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync()
{
@@ -34,7 +34,13 @@ public sealed class InputWaiterTests : IDisposable
[Fact]
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
{
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5));
// Use the no-timeout overload so that the wait can only be released by SignalInput.
// A finite timeout would make this test's logic racy: the component correctly
// honors the timeout, but if the test thread is starved of CPU time (CI load,
// GC pause) long enough for the timeout to fire, waitTask completes before
// SignalInput is called and the "should not complete before signaled" assertion
// flakes. Timeout behavior is covered separately below.
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100));
completedBeforeSignal.Should().NotBeSameAs(
@@ -100,6 +106,21 @@ public sealed class InputWaiterTests : IDisposable
this._waiter.SignalInput();
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync()
{
// Verify that a finite timeout releases the block even without a signal.
// We only assert that it *does* complete (within a generous outer bound);
// we intentionally do not assert that it stays blocked until the timeout,
// because that would re-introduce the same wall-clock flakiness
// described in BlocksUntilSignaledAsync (see comment on that test).
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300));
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires");
await waitTask;
}
}
public class OutputFilterTests
@@ -133,31 +133,31 @@ public sealed class ObservabilityTests : IDisposable
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Default");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
{
// Arrange
@@ -182,7 +182,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().ContainKey(Tags.WorkflowDefinition);
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task TelemetryDisabledByDefault_CreatesNoActivitiesAsync()
{
// Arrange
@@ -200,7 +200,7 @@ public sealed class ObservabilityTests : IDisposable
capturedActivities.Should().BeEmpty("No activities should be created when telemetry is disabled (default).");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WithOpenTelemetry_UsesProvidedActivitySourceAsync()
{
// Arrange
@@ -235,7 +235,7 @@ public sealed class ObservabilityTests : IDisposable
"All activities should come from the user-provided ActivitySource.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableWorkflowBuild_PreventsWorkflowBuildActivityAsync()
{
// Arrange
@@ -255,7 +255,7 @@ public sealed class ObservabilityTests : IDisposable
"WorkflowBuild activity should be disabled.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableWorkflowRun_PreventsWorkflowRunActivityAsync()
{
// Arrange
@@ -285,7 +285,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableExecutorProcess_PreventsExecutorProcessActivityAsync()
{
// Arrange
@@ -312,7 +312,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableEdgeGroupProcess_PreventsEdgeGroupProcessActivityAsync()
{
// Arrange
@@ -333,7 +333,7 @@ public sealed class ObservabilityTests : IDisposable
"Other activities should still be created.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task DisableMessageSend_PreventsMessageSendActivityAsync()
{
// Arrange
@@ -382,7 +382,7 @@ public sealed class ObservabilityTests : IDisposable
return builder.WithOpenTelemetry(configure: opts => opts.DisableMessageSend = true).Build();
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_LogsExecutorInputAndOutputAsync()
{
// Arrange
@@ -413,7 +413,7 @@ public sealed class ObservabilityTests : IDisposable
tags[Tags.ExecutorOutput].Should().Contain("HELLO", "Output should contain the transformed value.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_Disabled_DoesNotLogInputOutputAsync()
{
// Arrange
@@ -442,7 +442,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().NotContainKey(Tags.ExecutorOutput, "Output should NOT be logged when EnableSensitiveData is false.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_LogsMessageSendContentAsync()
{
// Arrange
@@ -474,7 +474,7 @@ public sealed class ObservabilityTests : IDisposable
tags.Should().ContainKey(Tags.MessageSourceId, "Source ID should be logged.");
}
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task EnableSensitiveData_Disabled_DoesNotLogMessageContentAsync()
{
// Arrange
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
@@ -157,4 +158,301 @@ public partial class WorkflowBuilderSmokeTests
workflow3.Name.Should().Be("Named Only");
workflow3.Description.Should().BeNull();
}
[Fact]
public void ForwardMessage_WithSingleTarget_CreatesDirectEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardMessage<string>(source, target)
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.Direct);
edge.DirectEdgeData.Should().NotBeNull();
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
edge.DirectEdgeData.Condition.Should().NotBeNull();
edge.DirectEdgeData.Condition!("message").Should().BeTrue();
edge.DirectEdgeData.Condition!(42).Should().BeFalse();
edge.DirectEdgeData.Condition!(null).Should().BeFalse();
}
[Fact]
public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target1 = new("target1");
NoOpExecutor target2 = new("target2");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardMessage<string>(source, [target1, target2], message => message == "match")
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]);
edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty();
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty();
}
[Fact]
public void ForwardExcept_WithSingleTarget_CreatesDirectEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardExcept<string>(source, target)
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.Direct);
edge.DirectEdgeData.Should().NotBeNull();
edge.DirectEdgeData!.SourceId.Should().Be(source.Id);
edge.DirectEdgeData!.SinkId.Should().Be(target.Id);
edge.DirectEdgeData.Condition.Should().NotBeNull();
edge.DirectEdgeData.Condition!("message").Should().BeFalse();
edge.DirectEdgeData.Condition!(42).Should().BeTrue();
edge.DirectEdgeData.Condition!(null).Should().BeTrue();
}
[Fact]
public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor target1 = new("target1");
NoOpExecutor target2 = new("target2");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.ForwardExcept<string>(source, [target1, target2])
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]);
edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty();
}
[Fact]
public void AddChain_CreatesSequentialDirectEdges()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor middle = new("middle");
NoOpExecutor end = new("end");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddChain(source, [middle, end])
.Build();
// Assert
Edge firstEdge = GetSingleEdge(workflow, source.Id);
firstEdge.Kind.Should().Be(EdgeKind.Direct);
firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id);
Edge secondEdge = GetSingleEdge(workflow, middle.Id);
secondEdge.Kind.Should().Be(EdgeKind.Direct);
secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id);
secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id);
}
[Fact]
public void AddChain_WhenExecutorRepeats_Throws()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor middle = new("middle");
// Act
Action act = () => new WorkflowBuilder(source.Id)
.AddChain(source, [middle, source]);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("executors");
}
[Fact]
public void AddExternalCall_CreatesRequestPortAndRoundTripEdges()
{
// Arrange
const string PortId = "port1";
NoOpExecutor source = new("start");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddExternalCall<string, int>(source, PortId)
.Build();
// Assert
workflow.Ports.Should().ContainKey(PortId);
workflow.Ports[PortId].Request.Should().Be(typeof(string));
workflow.Ports[PortId].Response.Should().Be(typeof(int));
workflow.ExecutorBindings.Should().ContainKey(PortId);
Edge requestEdge = GetSingleEdge(workflow, source.Id);
requestEdge.Kind.Should().Be(EdgeKind.Direct);
requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id);
requestEdge.DirectEdgeData.SinkId.Should().Be(PortId);
Edge responseEdge = GetSingleEdge(workflow, PortId);
responseEdge.Kind.Should().Be(EdgeKind.Direct);
responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId);
responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id);
}
[Fact]
public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault()
{
// Arrange
NoOpExecutor source = new("start");
NoOpExecutor stringTarget = new("string-target");
NoOpExecutor intTarget = new("int-target");
NoOpExecutor defaultTarget = new("default-target");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddSwitch(source, switchBuilder => switchBuilder
.AddCase<string>(message => message == "match", [stringTarget])
.AddCase<int>(message => message > 0, [intTarget])
.WithDefault([defaultTarget]))
.Build();
// Assert
Edge edge = GetSingleEdge(workflow, source.Id);
edge.Kind.Should().Be(EdgeKind.FanOut);
edge.FanOutEdgeData.Should().NotBeNull();
edge.FanOutEdgeData!.SourceId.Should().Be(source.Id);
edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]);
edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull();
edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]);
edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]);
edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]);
}
[Fact]
public void ForwardMessage_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardMessage<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardMessage<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardMessage<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardMessage<string>(source, []));
}
[Fact]
public void ForwardExcept_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardExcept<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardExcept<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardExcept<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardExcept<string>(source, []));
}
[Fact]
public void AddChain_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
NoOpExecutor otherTarget = new("other-target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddChain(source, [target]));
Assert.Throws<ArgumentNullException>("source", () => builder.AddChain(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, null!));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, [target, null!]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, source]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, otherTarget, target]));
}
[Fact]
public void AddExternalCall_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddExternalCall<string, int>(source, "port"));
Assert.Throws<ArgumentNullException>("source", () => builder.AddExternalCall<string, int>(null!, "port"));
Assert.Throws<ArgumentNullException>("portId", () => builder.AddExternalCall<string, int>(source, null!));
}
[Fact]
public void AddSwitch_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { }));
Assert.Throws<ArgumentNullException>("source", () => builder.AddSwitch(null!, _ => { }));
Assert.Throws<ArgumentNullException>("configureSwitch", () => builder.AddSwitch(source, null!));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, _ => { }));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase<string>(_ => true, [])));
}
[Fact]
public void SwitchBuilder_InvalidArguments_Throw()
{
// Arrange
SwitchBuilder switchBuilder = new();
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>("predicate", () => switchBuilder.AddCase<string>(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.AddCase<string>(_ => true, null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.AddCase<string>(_ => true, [target, null!]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.WithDefault(null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.WithDefault([target, null!]));
}
/// <summary>
/// Gets the only edge emitted by the specified workflow source.
/// </summary>
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
}
@@ -67,7 +67,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never
/// disposed because yield break in async iterators does not trigger using disposal.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_LockstepAsync()
{
// Arrange
@@ -111,7 +111,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default)
/// execution environment (StreamingRunEventStream).
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_OffThreadAsync()
{
// Arrange
@@ -156,7 +156,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// (StreamingRun.WatchStreamAsync) with the OffThread execution environment.
/// This matches the exact usage pattern described in the issue.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync()
{
// Arrange
@@ -203,7 +203,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// streaming invocation, even when using the same workflow in a multi-turn pattern,
/// and that each session gets its own session activity.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync()
{
// Arrange
@@ -264,7 +264,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// Verifies that all started activities (not just workflow_invoke) are properly stopped.
/// This ensures no spans are "leaked" without being exported.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync()
{
// Arrange
@@ -305,7 +305,7 @@ public sealed class WorkflowRunActivityStopTests : IDisposable
/// be parented under the workflow session span. The run activity should
/// still nest correctly under the session.
/// </summary>
[Fact(Skip = "Flaky test - temporarily disabled.")]
[Fact]
public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync()
{
// Arrange
+24 -1
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.4.0] - 2026-05-14
### Added
- **agent-framework-core**: Forward MCP tool call metadata ([#5815](https://github.com/microsoft/agent-framework/pull/5815))
- **agent-framework-core**: Support `list[str]` arguments for file-based skill scripts ([#5850](https://github.com/microsoft/agent-framework/pull/5850))
- **agent-framework-core**: Strip server-issued response item IDs under storage ([#5690](https://github.com/microsoft/agent-framework/pull/5690))
- **agent-framework-ag-ui**: Add tool result display channel ([#5762](https://github.com/microsoft/agent-framework/pull/5762))
- **agent-framework-ag-ui**: Promote to release candidate stage ([#5844](https://github.com/microsoft/agent-framework/pull/5844))
- **agent-framework-devui**: Improvements for DevUI ([#5840](https://github.com/microsoft/agent-framework/pull/5840))
### Changed
- **agent-framework-core**: [BREAKING — experimental skills API] Align file skill folder discovery with agentskills.io spec ([#5807](https://github.com/microsoft/agent-framework/pull/5807))
- **agent-framework-core**: [BREAKING — experimental skills API] Extract skill spec metadata into `SkillFrontmatter` ([#5775](https://github.com/microsoft/agent-framework/pull/5775))
- **agent-framework-devui**: [BREAKING] Tighten default access controls and CORS posture ([#5740](https://github.com/microsoft/agent-framework/pull/5740))
- **agent-framework-a2a**: [BREAKING] Migrate to a2a-sdk v1.0 ([#5752](https://github.com/microsoft/agent-framework/pull/5752))
### Fixed
- **agent-framework-a2a**: Fix A2A v1.0 non-streaming response and sample runtime issues ([#5849](https://github.com/microsoft/agent-framework/pull/5849))
- **agent-framework-foundry-hosting**: Reject path-traversal context IDs in checkpoint storage ([#5851](https://github.com/microsoft/agent-framework/pull/5851))
- **agent-framework-core**: Prevent MCP message_handler deadlock on notification reload ([#4866](https://github.com/microsoft/agent-framework/pull/4866))
## [1.3.0] - 2026-05-07
### Added
@@ -1050,7 +1071,9 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
+1 -1
View File
@@ -16,7 +16,7 @@ Status is grouped into these buckets:
| --- | --- | --- |
| `agent-framework` | `python/` | `released` |
| `agent-framework-a2a` | `python/packages/a2a` | `beta` |
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` |
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` |
| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` |
| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` |
| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` |
+1 -1
View File
@@ -42,7 +42,7 @@ request_handler = DefaultRequestHandler(
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
]
)
```
@@ -78,7 +78,7 @@ class A2AExecutor(AgentExecutor):
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler),
*create_jsonrpc_routes(request_handler, "/"),
],
)
@@ -365,6 +365,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
all_updates: list[AgentResponseUpdate] = []
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
# In non-streaming mode, accumulate intermediate status content so it
# can be surfaced when the terminal event arrives (mirroring v0.3.x
# behavior where the full Task history was available at completion).
pending_updates_by_task: dict[str, list[AgentResponseUpdate]] = {}
async for item in a2a_stream:
payload_type = item.WhichOneof("payload")
if payload_type == "message":
@@ -391,27 +395,55 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
if task.status.state in TERMINAL_TASK_STATES:
streamed_artifact_ids_by_task.pop(task.id, None)
# If the terminal Task has no content, flush accumulated updates
if not updates or all(not u.contents for u in updates):
pending = pending_updates_by_task.pop(task.id, [])
for update in pending:
all_updates.append(update)
yield update
else:
pending_updates_by_task.pop(task.id, None)
for update in updates:
all_updates.append(update)
yield update
elif payload_type == "status_update":
status_event = item.status_update
updates = self._updates_from_task_update_event(status_event)
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
elif is_terminal:
if updates:
# Terminal event with content — discard accumulated intermediates
pending_updates_by_task.pop(status_event.task_id, None)
for update in updates:
all_updates.append(update)
yield update
else:
# Terminal event with NO content — flush accumulated updates
pending = pending_updates_by_task.pop(status_event.task_id, [])
for update in pending:
all_updates.append(update)
yield update
else:
# Non-streaming intermediate: accumulate for later
if updates:
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
elif payload_type == "artifact_update":
artifact_event = item.artifact_update
updates = self._updates_from_task_update_event(artifact_event)
# Always yield artifact updates — they carry actual response
# content (files, data). Track IDs so that a subsequent
# terminal Task doesn't duplicate the same artifacts.
if updates:
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
artifact_event.artifact.artifact_id
)
if emit_intermediate:
for update in updates:
all_updates.append(update)
yield update
for update in updates:
all_updates.append(update)
yield update
else:
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"a2a-sdk>=1.0.0,<2",
]
@@ -1570,4 +1570,102 @@ async def test_none_metadata_leaves_additional_properties_empty(
assert not response.additional_properties
async def test_non_streaming_terminal_status_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from terminal status_update events."""
completed_msg = A2AMessage(
message_id="msg-complete",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Done! Here is your answer.")],
)
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
event = TaskStatusUpdateEvent(task_id="task-ts", context_id="ctx-ts", status=status)
mock_a2a_client.responses.append(StreamResponse(status_update=event))
response = await a2a_agent.run("Hello")
assert len(response.messages) == 1
assert response.messages[0].text == "Done! Here is your answer."
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
# Intermediate WORKING event with content
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Here is your answer from working state.")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event with NO content
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
completed_event = TaskStatusUpdateEvent(task_id="task-acc", context_id="ctx-acc", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# The accumulated WORKING content is flushed when terminal arrives empty
assert len(response.messages) == 1
assert response.messages[0].text == "Here is your answer from working state."
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming: if terminal event has content, intermediate content is discarded."""
# Intermediate WORKING event
working_msg = A2AMessage(
message_id="msg-working",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Still thinking...")],
)
working_status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=working_msg)
working_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=working_status)
mock_a2a_client.responses.append(StreamResponse(status_update=working_event))
# Terminal COMPLETED event WITH content
completed_msg = A2AMessage(
message_id="msg-final",
role=A2ARole.ROLE_AGENT,
parts=[Part(text="Final answer")],
)
completed_status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=completed_msg)
completed_event = TaskStatusUpdateEvent(task_id="task-wi", context_id="ctx-wi", status=completed_status)
mock_a2a_client.responses.append(StreamResponse(status_update=completed_event))
response = await a2a_agent.run("Hello")
# Terminal content supersedes accumulated intermediates
assert len(response.messages) == 1
assert response.messages[0].text == "Final answer"
async def test_non_streaming_artifact_update_surfaces_content(
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
) -> None:
"""Non-streaming run() should surface content from artifact_update events."""
artifact = Artifact(
artifact_id="art-ns",
parts=[Part(text="Artifact content")],
)
event = TaskArtifactUpdateEvent(task_id="task-anu", context_id="ctx-anu", artifact=artifact, append=False)
mock_a2a_client.responses.append(StreamResponse(artifact_update=event))
# Terminal task with the same artifact ID — should be deduped
mock_a2a_client.add_task_response("task-anu", [{"id": "art-ns", "content": "Artifact content"}])
response = await a2a_agent.run("Hello")
# Artifact update + terminal task with same artifact ID = content emitted once from
# the artifact_update, then the duplicate from the task is filtered by streamed_artifact_ids
assert len(response.messages) == 1
assert response.messages[0].text == "Artifact content"
# endregion
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260507"
version = "1.0.0rc1"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-foundry>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-foundry>=1.4.0,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
@@ -21,6 +21,7 @@ from chatkit.types import (
HiddenContextItem,
ImageAttachment,
SDKHiddenContextItem,
StructuredInputItem,
TaskItem,
ThreadItem,
UserMessageItem,
@@ -527,6 +528,9 @@ class ThreadItemConverter:
case GeneratedImageItem():
# TODO(evmattso): Implement generated image handling in a future PR
return []
case StructuredInputItem():
# TODO(evmattso): Implement structured input handling in a future PR
return []
case _:
assert_never(item)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260507"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-core>=1.4.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]

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