mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Python: Merge main into feature-durabletask-python branch (#3160)
* Python: Add factory pattern to concurrent orchestration builder (#2738) * Add factory pattern to concurrent orchestration builder * Update readme * Address AI comments * Fix unit tests * Fix import * Prevent multiple calls to set participants or factories * Add comments * Mitigate warnings * Fix mypy * Address comments * Address Copilot comments * Fix tests * Python: fix: GroupChat ManagerSelectionResponse JSON Schema for OpenAI Structured Outpu… (#2750) * fix: ManagerSelectionResponse JSON Schema for OpenAI Structured Output Strict Mode * refactor: install pre-commit then commit again * Capture file IDs from code interpreter in streaming responses (#2741) * .NET: [BREAKING] Prevent nulls in AIAgent property (#2719) * prevent nulls in AIAgent property * address feedback * code ql sm04598 (#2723) Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> * .NET: Add Conversation State Sample (Step05) (#2697) * Initial plan * Add Agent_OpenAI_Step05_Conversation sample for conversation state management Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Update Program.cs comment to accurately describe the sample Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Update the code to use the ConversationClient more in line with the samples in OpenAI * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Changing sample to use ChatClientAgent and conversationId in GetNewThread --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.7 to 4.0.4.11 (#2777) --- updated-dependencies: - dependency-name: AWSSDK.Extensions.Bedrock.MEAI dependency-version: 4.0.4.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump Azure.Identity from 1.17.0 to 1.17.1 (#2780) --- updated-dependencies: - dependency-name: Azure.Identity dependency-version: 1.17.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.Identity dependency-version: 1.17.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.Identity dependency-version: 1.17.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.Identity dependency-version: 1.17.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump Azure.AI.AgentServer.AgentFramework from 1.0.0-beta.4 to 1.0.0-beta.5 (#2778) --- updated-dependencies: - dependency-name: Azure.AI.AgentServer.AgentFramework dependency-version: 1.0.0-beta.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.AI.AgentServer.AgentFramework dependency-version: 1.0.0-beta.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.AI.AgentServer.AgentFramework dependency-version: 1.0.0-beta.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Python: added more complete parsing for mcp tool arguments (#2756) * added more complete parsing for mcp tool arguments * fixed mypy * added nonlocal model counter, and some fixes * fixes in naming logic * extracted json parsing function, added parametrized test and checked coverage * Python: Updated package versions (#2784) * Updated package versions * Small fix * Bump actions/checkout from 5 to 6 (#2404) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * .NET: adds support for labels in edges, fixes rendering of labels in dot a… (#1507) * adds support for labels in edges, fixes rendering of labels in dot and mermaid, adds rendering of labels in edges * Update dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * escaping edge labels, adding tests for labels containing strange characters that would break the diagram and enabling the previous signature so the API has backwards compatibility. * Unify label in EdgeData * Edge API adjustments, removed useless "sanitizer" * fixed test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jacob Alber <jaalber@microsoft.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * Python: Added custom args and thread object to ai_function kwargs (#2769) * Added an example of using kwargs in ai_function * Added thread object to ai_function kwargs * Updated docs * Small fix * Added thread parameter filtering * Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774) * Update OpenAIResponses.yaml to match AgentSchema (#2598) 1. Update `connection` child types -- `kind: ApiKey` to `kind: key` otherwise schema will fail: https://microsoft.github.io/AgentSchema/reference/apikeyconnection/ 2. Update `outputSchema`'s `PropertySchema` to be `kind` instead of `type` otherwise schema will fail: https://microsoft.github.io/AgentSchema/reference/propertyschema/ * Python: Remove warnings from workflow builder on not using factories (#2808) * Revert concurrent * Fix comments * Python: Filter framework kwargs from MCP tool invocations (#2870) * Filter framework kwargs from MCP tool invocations * Fixes * Python: Fix WorkflowAgent to emit yield_output as agent response (#2866) * Fix WorkflowAgent to emit yield_output as agent response * use raw_representation * Raw representation handling * Python: Use agent description in HandoffBuilder auto-generated tools (#2713) (#2714) ## Summary Enhanced `HandoffBuilder._apply_auto_tools` to use the target agent's description when creating handoff tools, providing more informative tool descriptions for LLMs. ## Changes - Modified `_apply_auto_tools` to extract `description` from `AgentExecutor._agent` when available - Updated iteration to use `.items()` for more efficient dict traversal - Handoff tools now use agent descriptions instead of generic placeholders ## Example Before: "Handoff to the refund_agent agent." After: "You handle refund requests. Ask for order details and process refunds." ## Testing - All handoff tests pass (20/20) - No breaking changes to existing API Fixes #2713 Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> * Python: [BREAKING] Observability updates (#2782) * fixes Python: Add env_file_path parameter to setup_observability() similar to AzureOpenAIChatClient Fixes #2186 * WIP on updates using configure_azure_monitor * improved setup and clarity * fixed root .env.example * revert changes * updated files * updated sample * updated zero code * test fixes and fixed links * fix devui * removed planning docs * added enable method and updated readme and samples * clarified docstring * add return annotation * updated naming * update capatilized version * updated readme and some fixes * updated decorator name inline with the rest * feedback from comments addressed * Python: Fix middleware terminate flag to exit function calling loop immediately (#2868) * Fix middleware terminate flag to exit function calling loop immediately * Eliminating duck typing * Improve function exec result handling * Fix race condition * Fix mypy issues * Python: Fix context duplication in handoff workflows when restoring from checkpoint (#2867) * Fix context duplication in handoff workflows when restoring from checkpoint * Address Copilot PR review * .NET: Update to latest Azure.AI.*, OpenAI, and M.E.AI* (#2850) * Update to latest Azure.AI.*, OpenAI, and M.E.AI* Absorb breaking changes in Responses surface area * Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs * Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs * Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs * Update dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Using patch to remove the model is necessary, updated the response client to actually use the the ForAgent --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * Bump actions/download-artifact from 6 to 7 (#2862) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/cache from 4 to 5 (#2861) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/upload-artifact from 5 to 6 (#2860) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Python : Ollama Connector for Agent Framework (#1104) * Initial Commit for Olama Connector * Added Olama Sample * Add Sample & Fixed Open Telemetry * Fixed Spelling from Olama to Ollama * remove"opentelemetry-semantic-conventions-ai ~=0.4.13" since its handled in a different pr * Added Tool Calling * Finalizing test cases * Adjust samples to be more reliable * Update python/packages/ollama/agent_framework_ollama/_chat_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/ollama/pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/ollama/tests/test_ollama_chat_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/ollama/agent_framework_ollama/_chat_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improved Docstrings & Sample * Update python/packages/ollama/agent_framework_ollama/_chat_client.py Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com> * Integrate PR Feedback - Divided Streaming and Non-Streaming into independent Methods - Catch Ollama Validation Error - Add OTEL Provider Name - Checked Ollama Messages - Add Usage Statistics * Revert setting, so it can be none * Validate Message formatting between AF and Ollama * Catch Ollama Error and raise a ServiceResponse Error * Fix mypy error * remove .vscode comma * Add Reasoning support & adjust to new structure * Add Ollama Multimodality and Reasoning * Add test cases for reasoning * Add Tests for Error Handling in Ollama Client * Update python/samples/getting_started/multimodal_input/ollama_chat_multimodal.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Integrated Copilot Feedback * Implement first PR Feedback * Adjust Readme files for examples * Adjust argument passing via additional chat options * Implemented PR Feedback * Removing Ollama Package from Core and moving samples * Fix Link & Adding Samples to Main Sample Readme * Fixing Links in Readme * Moved Multimodal and Chat Example * Fixed Link in ChatClient to Ollama * Fix AgentFramework Links in Ollama Project * Fix observability breaking change --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com> * Skip failing IT (#2904) * .NET: Cosmos DB UT Fast Skip (For Non-Configured Local envs) (#2906) * Cosmos DB UT Fast Skip (Non-Configured Local envs) + Long running UT skip in pipeline when no CosmosDB changes happened * Force a CosmosDB source code change to trigger the pipeline * Address possible string boolean mismatch * Add debug * Enabling emulator always when running IT * .NET: Add TTLs to durable agent sessions (#2679) * .NET: Add TTLs to durable agent sessions * Remove unnecessary async * PR feedback: clarify UTC * PR feedback: limit minimum signal delay to <= 5 minutes * PR feedback: Fix TTL disablement * Linter: use auto-property * Fix build break from OpenAI SDK change * Updated CHANGELOG.md * PR feedback * Reduce default TTL to 14 days to work around DTS bug * Python: Update Mem0Provider to use v2 search API `filters` parameter (#2766) * short fix to move id parameters to filters object * added tests * small fix * mem0 dependency update * Updated package versions (#2913) * .NET: Switch to new "Run" method name. (#2843) * Switch to new "RunAgent" method name. * Try to disable false positive naming warning. * Add comment about disabled warnings. * Rename `RunAgent` to just `Run`. * Update CHANGELOG. * Python: Switch to new "run" method name. (#2890) * Switch to `run` method. * Add support for deprecated `run_agent`. * Fix entity method name. * Fix method name and improve tests. * Update comment. * Update Python CHANGELOG. * [BREAKING] Python: Add factory pattern to handoff orchestration builder (#2844) * WIP: Factory pattern to handoff * Add factory pattern to concurrent orchestration builder; Next: tests and sample verification * Add tests and improve comments * Fix mypy * Simplify handoff_simple.py * Simplify handoff_autonoumous.py and bug fix * Update readme * Address Copilot comments * Python: Flow custom kwargs to agents via Workflow SharedState (#2894) * Flow custom kwargs to agents via SharedState * Address Copilot feedback * Improve sample typing * Fix test * Fix Pydantic error when using Literal type for tool params (#2893) * Updated Ollama package version (#2920) * Python: Azure AI Agent with Bing Grounding Citations Sample (#2892) * bing grounding sample with citations * small fix * fix * .NET: Make DelegatingAIAgent abstract (#2797) * Initial plan * Make DelegatingAIAgent abstract Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Added additional arguments for Azure AI agent (#2922) * Python: Correction of MCP image type conversion in _mcp.py (#2901) * Correction of MCP image type conversion in _mcp.py * Added a new overload to the init function of the DataContent() type of the Agent Framework, edited the test case to correctly test the usage of the data and uri fields while using DataContent() * Fixed tests related to the changes of the DataContent type, added testing for both string and byte representations * Pass kwargs into subworkflows (#2923) * Python: Move ollama samples to samples getting started dir (#2921) * Move ollama samples to samples getting started dir * Address feedback * Python: fix: correct BadRequestError when using Pydantic model in response_fo… (#1843) * fix: correct BadRequestError when using Pydantic model in response_format * Fix lint --------- Co-authored-by: Evan Mattson <evan.mattson@microsoft.com> * .NET: [Breaking] Delete display name property (#2758) * delete the AIAgent.DisplayName property * use agent name as a first value for activity display name * Update dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Python: cleanup and refactoring of chat clients (#2937) * refactoring and unifying naming schemes of internal methods of chat clients * set tool_choice to auto * fix for mypy * added note on naming and fix #2951 * fix responses * fixes in azure ai agents client * Python: Workflow add option to visualize internal executors (#2917) * Workflow add option to visualize internal executors * Address Copilot comments * Python: Fixes Run ID and Thread ID casing to align with AG-UI Typescript SDK (#2948) * added camelCase input to run id and thread id aligning with @ag-ui/core * fixed per copilot suggestions * Python: Add workflow cancellation sample (#2732) * Add workflow cancellation sample Add sample demonstrating how to cancel a running workflow using asyncio tasks. Shows both cancellation mid-execution and normal completion paths. Useful for implementing timeouts, graceful shutdown, or A2A executors. * update docstring * .NET: Update Anthropic package to version 12.0.0 (#2914) * Initial plan * Update Anthropic package to version 12.0.0 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Python: Add Azure Managed Redis Support with Credential Provider (#2887) * azure redis support * small fixes * azure managed redis sample * fixes * Bump CommunityToolkit.Aspire.OllamaSharp from 13.0.0-beta.440 to 13.0.0 (#2856) --- updated-dependencies: - dependency-name: CommunityToolkit.Aspire.OllamaSharp dependency-version: 13.0.0 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.11 to 4.0.5 (#2853) --- updated-dependencies: - dependency-name: AWSSDK.Extensions.Bedrock.MEAI dependency-version: 4.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> * Bump Azure.AI.AgentServer.AgentFramework from 1.0.0-beta.4 to 1.0.0-beta.5 (#2854) --- updated-dependencies: - dependency-name: Azure.AI.AgentServer.AgentFramework dependency-version: 1.0.0-beta.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Azure.AI.AgentServer.AgentFramework dependency-version: 1.0.0-beta.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946) * Fix kwargs propagation through workflow.as_agent() * Fix WorkflowAgent to respect AgentExecutor output_response setting * .NET: Use GrpcEntityRunner instead of TaskEntityDispatcher (#2759) * Use GrpcEntityRunner instead of TaskEntityDispatcher * Pin to Durable worker 1.11.0 * Set the invocation result * Update all Durable packages * Update changelog, rename dispatcher to encondedEntityRequest * Python: Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG (#2968) * Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG * update lock * Fix formatting * Fix ChatKit typing * Python: Introducing Foundry Local Chat Clients (#2915) * redo foundry local chat client * fix mypy and spelling * better docstring, updated sample * fixed tests and added tests * small sample update * Updated package versions (#2978) * Python: Added GitHub MCP sample with PAT (#2967) * added github mcp sample with PAT * addressed copilot fixes * env fix * Python: Preserve reasoning blocks with OpenRouter (#2950) * Preserve reasoning blocks with OpenRouter * Put encrypted reasoning in TextReasoningContent * Remove unneccessary change * Fix docs * Support streaming * Fix handling None in TextReasoningContent.text * Python: Added response.created and response.in_progress event process to OpenAIBaseResponseClient (#2975) * added response.created and response.in_progress to include response.id * better doc string * added tests for the new streaming event types * Python: Introducing support for Bedrock-hosted models (Anthropic, Cohere, etc.) (#2610) * Pushing the bedrock related changes to the new branch after addressing the review comments * 2524 Addressed the second round review comments * 2524 Addressed few more minor comments on the PR * resolving the merge conflict * 2524 resolved the uv.lock conflicts * 2524 addressed more comments * 2524 removed the print statement to fix the checks failure * 2524 resolved the CI failure issues * 2524 fixing the CI breaks * 2524 Addressed the review comment * 2524 resolved conflict --------- Co-authored-by: Sunil Dutta <sunil.dutta@penske.com> Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com> * .NET: [Durable Agents] Reliable streaming sample (#2942) * .NET: [Durable Agents] Reliable streaming sample * Add automated validation for new sample * Address Copilot PR feedback * Fix typo in README.md about agent definitions (#2634) * Fix typo in README.md about agent definitions * Update agent-samples/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Python: latency improvements (#3014) * latency improvements * fixed mypy, added coding standards and instructions * slight logic improvement * Python: Updated package versions (#3024) * Updated package versions * Updated changelog * Python: add powerfx safe mode (#3028) * add powerfx safe mode * improved docstring and aligned env_file loading * ensured test uses reset * .NET: [Breaking] Introduce RunCoreAsync/RunCoreStreamingAsync delegation pattern in AIAgent (#2749) * Initial plan * Refactor AIAgent: Make RunAsync and RunStreamingAsync non-abstract, add RunCoreAsync and RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix infinite recursion in test implementations Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Make RunAsync and RunStreamingAsync non-virtual as requested Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix DelegatingAIAgent subclasses to use RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix XML documentation references in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Restore <see cref> tags with proper qualified signatures in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Rollback unnecessary XML documentation changes in AnonymousDelegatingAIAgent Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Remove pragma and update crefs to RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix EntityAgentWrapper to call base.RunCoreAsync/RunCoreStreamingAsync Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * fix compilation issues * fix compilatio issue * fix tests * fix unit tests * fix unit test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * add issue template and additional labeling (#3006) * fix and extra int test (#3037) * .NET: [BREAKING] Refactor ChatMessageStore methods to be similar to AIContextProvider and add filtering support (#2604) * Refactor ChatMessageStore methods to be similar to AIContextProvider * Fix file encoding * Ensure that AIContextProvider messages area also persisted. * Update formatting and seal context classes * Improve formatting * Remove optional messages from constructor and add unit test * Add ChatMessageStore filtering via a decorator * Update sample and cosmos message store to store AIContextProvider messages in right order. Fix unit tests. * Update Workflowmessage store to use aicontext provider messages. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Improve xml docs messaging * Address code review comments. * Also notify message store on failure --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * [BREAKING] Remove unused AgentThreadMetadata (#3067) * Remove unused AgentThreadMetadata * Update DurableTask Changelog * Python: Fix AzureAIClient failure when conversation history contains assistant messages (#3076) * Fix AzureAIClient failure when conversation history contains assistant messages * Address PR review feedback: improve docstring and test assertions * Remove redundant cast * Fix: Update OTLP exporter protocol conditions (#3070) * Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090) * Fix ExecutorInvokedEvent.data mutation bug * Fix bug related to not yielding output type * .NET: Seal ChatClientAgentThread (#2842) * Initial plan * Seal ChatClientAgentThread class Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * Fix broken strands urls. (#3102) * Fix broken strands urls. * Fix typos * .NET: Fix message ordering inconsistency when using AIContextProvider (#2659) * Initial plan * Fix message ordering inconsistency when using AIContextProvider Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Revert to original message ordering: Input, AIContextProvider, Response Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Reorder messages to ChatClient to match MessageStore order: Existing, Input, AIContextProvider Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Remove redundant test methods as existing tests already verify the behavior Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * fix: tool_choice parameter not being honored when passed to agent.run() (#3095) * sharepoint sample fix (#3108) * Bump versions to 1.0.0b260106 for a release. Update CHANGELOG.md (#3109) * Bump Bedrock version to latest (#3110) * Python: Fix MCP tool result serialization for list[TextContent] (#2523) * Fix MCP tool result serialization for list[TextContent] When MCP tools return results containing list[TextContent], they were incorrectly serialized to object repr strings like: '[<agent_framework._types.TextContent object at 0x...>]' This fix properly extracts text content from list items by: 1. Checking if items have a 'text' attribute (TextContent) 2. Using model_dump() for items that support it 3. Falling back to str() for other types 4. Joining single items as plain text, multiple items as JSON array Fixes #2509 * Address PR review feedback for MCP tool result serialization - Extract serialize_content_result() to shared _utils.py - Fix logic: use texts[0] instead of join for single item - Add type annotation: texts: list[str] = [] - Return empty string for empty list instead of '[]' - Move import json to file top level - Add comprehensive unit tests for serialization * Address PR review feedback: fix type checking and double serialization - Add isinstance(item.text, str) check to ensure text attribute is a string - Fix double-serialization issue by keeping model_dump results as dicts until final json.dumps (removes escaped JSON strings in arrays) - Improve docstring with detailed return value documentation - Add test for non-string text attribute handling - Add tests for list type tool results in _events.py path * Simplify PR: minimal changes to fix MCP tool result serialization Addresses reviewer feedback about excessive refactoring: - Reset _events.py to original structure - Only add import and use serialize_content_result in one location - All review comments addressed in serialize_content_result(): - Added isinstance(item.text, str) check - Use model_dump(mode="json") to avoid double-serialization - Improved docstring with explicit return value documentation - Empty list returns "" instead of "[]" * Refactor: Move MCP TextContent serialization to core prepare_function_call_results Per reviewer feedback, moved the TextContent serialization logic from ag-ui's serialize_content_result to the core package's prepare_function_call_results function. Changes: - Added handling for objects with 'text' attribute (like MCP TextContent) in _prepare_function_call_results_as_dumpable - Removed serialize_content_result from ag-ui/_utils.py - Updated _events.py and _message_adapters.py to use prepare_function_call_results from core package - Updated tests to match the core function's behavior * Fix failing tests for prepare_function_call_results behavior - test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None) - test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class * Fix B903 linter error: Convert MockTextContent to dataclass The ruff linter was reporting B903 (class could be dataclass or namedtuple) for the MockTextContent test helper classes. This commit converts them to dataclasses to satisfy the linter check. * Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742) * Improve DevUI, add Context Inspector view as new tab under traces * fix mypy errors * fix: Handle stale MCP connections in DevUI executor MCP tools can become stale when HTTP streaming responses end - the underlying stdio streams close but `is_connected` remains True. This causes subsequent requests to fail with `ClosedResourceError`. Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before agent execution. This is a workaround for an upstream Agent Framework issue where connection state isn't properly tracked. Fixes MCP tools failing on second HTTP request in DevUI. fixes #1476 #1515 #2865 * fix #1572 report import dependency errors more clearly * Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode? * remove unused dead code * improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly * update ui build * update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes * .NET: Seal factory contexts and add non JSO deserialize overloads (#3066) * Seal factory contexts and add non JSO deserialize overloads * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Enable blank issues in issue template configuration Need to re-enable creating blank issues * updated templates (#3106) * updated templates * enabled blank and fixed triage * made language optional and moved to the bottom for features * Python: Streaming sample for azurefunctions (#3057) * Streaming sample for azurefunctions * Fixed links and sample name * Addressed feedback * Addressed feedback * Fixed integration tests * Updated test * Python: fix(azure-ai): Fix response_format handling for structured outputs (#3114) * fix(azure-ai): read response_format from chat_options instead of run_options * refactor: use explicit None checks for response_format * Fix mypy error * Mypy fix * Python: Bump python version to 1.0.0b260107 for a release (#3128) * Bump python version to 1.0.0b260107 for a release * Update changelog * Make A2AAgent public, so that it's concrete implementation methods can be used. (#3119) * .NET: Map additional props <-> A2A metadata (#3137) * map additional props from agent run options to a2a request metadata * small touches * add unit tests for new extension methods * Sort using * add unit test * add additiona unit tests * special case json element to avoid unnecessary serialization * Python: Fix Anthropic streaming response bugs (#3141) * test commit identity * fix(anthropic): fix raw_representation and finish_reason in streaming * lint fix * Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.5 to 4.0.5.1 (#2994) --- updated-dependencies: - dependency-name: AWSSDK.Extensions.Bedrock.MEAI dependency-version: 4.0.5.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * Bump Anthropic from 12.0.0 to 12.0.1 (#2993) --- updated-dependencies: - dependency-name: Anthropic dependency-version: 12.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * .NET: [Breaking] Prevent loss of input messages & streamed updates when resuming streaming (#2748) * save input messages and stream updates to the continuation token to be able to use them in the last successful stream resumption call. * Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix typo * init continuation token from chat response * remove unnecessary types for source generation * remove check for continuation token passed at initial run * remove check for continuation token pass at initial run * centralize continuation token parsing * update xml comments * use readonly collection instead of enumerable --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * .NET: fix: Expose WorkflowErrorEvent as ErrorContent (#2762) * fix: Expose WorkflowErrorEvent as ErrorContent When hosted using .AsAgent(), Workflows were not exposing inner errors coming as Exceptions (through the WorkflowErrorEvent) The fix is to convert their message to an ErrorContent on the way out, rather than rely on the default "empty update" to collect the raw event. * feat: Add a way to show/suppress exception information * Bump Microsoft.Agents.AI.Workflows from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1 (#2997) --- updated-dependencies: - dependency-name: Microsoft.Agents.AI.Workflows dependency-version: 1.0.0-preview.251219.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> * .NET: Add Run overloads to expose ChatClientAgentRunOptions in IntelliSense (#3115) * Initial plan * Add ChatClientAgentExtensions for improved discoverability of ChatClientAgentRunOptions Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Address code review feedback - use collection expression syntax Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Apply suggestion from @westey-m * Fix issues with Copilot implementation * Add additional tests for structured output overloads. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Python: Add tool call/result content types and update connectors and samples (#2971) * Add new AI content types and image tool support Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Add Python content types for tool calls/results and image generation tool support Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Address review feedback for tool content and samples Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Tighten image generation typing and sample tools list Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Align image generation output typing Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Handle MCP naming, image options mapping, and connector tool content Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Allow MCP call in function approval request Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Remove raw image_generation tool remapping Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Restore Anthropic tool_use to function calls unless code execution Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Fix lint issues for hosted file docstring and MCP parsing Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Import ChatResponse types in Anthropic client Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Fix Anthropics citation type imports and MCP typing for handoff/tools Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Skip lightning tests without agentlightning and fix function call import Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * fix lint on lab package * rebuilt anthropic parsing * redid anthropic parsing * typo * updated parsing and added missing docstrings * fix tests * mypy fixes * second mypy fix * add new class to other samples --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu> * Bump Google.GenAI from 0.6.0 to 0.9.0 (#2995) --- updated-dependencies: - dependency-name: Google.GenAI dependency-version: 0.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> * Bump js-yaml from 4.1.0 to 4.1.1 in /python/packages/devui/frontend (#3123) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.1.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Updated package versions (#3144) * .NET: Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI (#2996) * Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI Bumps Microsoft.Agents.AI.OpenAI from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1 Bumps Microsoft.Extensions.AI.OpenAI from 10.1.0-preview.1.25608.1 to 10.1.1-preview.1.25612.2 --- updated-dependencies: - dependency-name: Microsoft.Agents.AI.OpenAI dependency-version: 1.0.0-preview.251219.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.Extensions.AI.OpenAI dependency-version: 10.1.1-preview.1.25612.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.Agents.AI.OpenAI dependency-version: 1.0.0-preview.251219.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.Extensions.AI.OpenAI dependency-version: 10.1.1-preview.1.25612.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed samples --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> * Python: fix(ag-ui): Execute tools with approval_mode, fix shared state, code cleanup (#3079) * fix(ag-ui): execute tools after approval in human-in-the-loop flow * Fix shared state bug * Bug fix finalized * Refactoring to clean up code * Code cleanup * More fixes * More code cleanup * Add version detection in __init__.py to ruff ignore list * Track agent name with updates for workflow agent (#3146) * Python: Fix AzureAIClient tool call bug for AG-UI use (#3148) * Fiz AzureAIClient tool call bug * Address copilot feedback * Revert to match main * revert file to main --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Tao Chen <taochen@microsoft.com> Co-authored-by: Kurt <65111699+q33566@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Jose Luis Latorre Millas <joslat@gmail.com> Co-authored-by: Jacob Alber <jaalber@microsoft.com> Co-authored-by: Richard Ortega <richardjortega@gmail.com> Co-authored-by: 刘邦学AI <lbbniu@gmail.com> Co-authored-by: Stephen Toub <stoub@microsoft.com> Co-authored-by: Nico Möller <nkm-moeller@mail.de> Co-authored-by: Chris Gillum <cgillum@microsoft.com> Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com> Co-authored-by: Phillip Hoff <phillip.hoff@gmail.com> Co-authored-by: Ege Ozan Özyedek <36128615+egeozanozyedek@users.noreply.github.com> Co-authored-by: samueljohnsiby <66901393+samueljohnsiby@users.noreply.github.com> Co-authored-by: Evan Mattson <evan.mattson@microsoft.com> Co-authored-by: Hao Luo <338265+howlowck@users.noreply.github.com> Co-authored-by: Victor Dibia <chuvidi2003@gmail.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Jacob Viau <javia@microsoft.com> Co-authored-by: SuperKenVery <39673849+SuperKenVery@users.noreply.github.com> Co-authored-by: Sunil Dutta <dutta.2003@gmail.com> Co-authored-by: Sunil Dutta <sunil.dutta@penske.com> Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com> Co-authored-by: Syrine Chelly <62653967+SyChell@users.noreply.github.com> Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com> Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: takanori-terai <123897708+takanori-terai@users.noreply.github.com> Co-authored-by: claude89757 <138977524+claude89757@users.noreply.github.com> Co-authored-by: Gavin Aguiar <80794152+gavin-aguiar@users.noreply.github.com> Co-authored-by: Sukeesh <vsukeeshbabu@gmail.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
This commit is contained in:
committed by
GitHub
Unverified
parent
e3eff65a6b
commit
1e36ba33c4
@@ -11,21 +11,61 @@ from typing import Any
|
||||
|
||||
|
||||
class ConfirmationStrategy(ABC):
|
||||
"""Strategy for generating confirmation messages during human-in-the-loop flows."""
|
||||
"""Strategy for generating confirmation messages during human-in-the-loop flows.
|
||||
|
||||
Subclasses must define the message properties. The methods use those properties
|
||||
by default, but can be overridden for complete customization.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def approval_header(self) -> str:
|
||||
"""Header for approval accepted message. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def approval_footer(self) -> str:
|
||||
"""Footer for approval accepted message. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def rejection_message(self) -> str:
|
||||
"""Message when user rejects. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def state_confirmed_message(self) -> str:
|
||||
"""Message when state is confirmed. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def state_rejected_message(self) -> str:
|
||||
"""Message when state is rejected. Must be overridden."""
|
||||
...
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user approves function execution.
|
||||
|
||||
Default implementation uses header/footer properties.
|
||||
Override for complete customization.
|
||||
|
||||
Args:
|
||||
steps: List of approved steps with 'description', 'status', etc.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
message_parts = [self.approval_header.format(count=len(enabled_steps))]
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
message_parts.append(self.approval_footer)
|
||||
return "".join(message_parts)
|
||||
|
||||
@abstractmethod
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user rejects function execution.
|
||||
|
||||
@@ -35,141 +75,143 @@ class ConfirmationStrategy(ABC):
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.rejection_message
|
||||
|
||||
@abstractmethod
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate message when user confirms predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.state_confirmed_message
|
||||
|
||||
@abstractmethod
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate message when user rejects predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.state_rejected_message
|
||||
|
||||
|
||||
class DefaultConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Generic confirmation messages suitable for most agents.
|
||||
"""Generic confirmation messages suitable for most agents."""
|
||||
|
||||
This preserves the original behavior from v1.
|
||||
"""
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Executing {count} approved steps:\n\n"
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic approval message with step list."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nAll steps completed successfully!"
|
||||
|
||||
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll steps completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! What would you like me to change about the plan?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate generic state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Changes confirmed and applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate generic state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What would you like me to change?"
|
||||
|
||||
|
||||
class TaskPlannerConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for task planning agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Executing your requested tasks:\n\n"
|
||||
|
||||
message_parts = ["Executing your requested tasks:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nAll tasks completed successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll tasks completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! Let me revise the plan. What would you like me to change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Tasks confirmed and ready to execute!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! How should I adjust the task list?"
|
||||
|
||||
|
||||
class RecipeConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for recipe agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Updating your recipe:\n\n"
|
||||
|
||||
message_parts = ["Updating your recipe:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nRecipe updated successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nRecipe updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! What ingredients or steps should I change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate recipe-specific state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Recipe changes applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate recipe-specific state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What would you like me to adjust in the recipe?"
|
||||
|
||||
|
||||
class DocumentWriterConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for document writing agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Applying your edits:\n\n"
|
||||
|
||||
message_parts = ["Applying your edits:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nDocument updated successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nDocument updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! Which changes should I keep or modify?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate document-specific state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Document edits applied!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate document-specific state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What should I change about the document?"
|
||||
|
||||
|
||||
def apply_confirmation_strategy(
|
||||
strategy: ConfirmationStrategy | None,
|
||||
accepted: bool,
|
||||
steps: list[dict[str, Any]],
|
||||
) -> str:
|
||||
"""Apply a confirmation strategy to generate a message.
|
||||
|
||||
This helper consolidates the pattern used in multiple orchestrators.
|
||||
|
||||
Args:
|
||||
strategy: Strategy to use, or None for default
|
||||
accepted: Whether the user approved
|
||||
steps: List of steps (may be empty for state confirmations)
|
||||
|
||||
Returns:
|
||||
Generated message string
|
||||
"""
|
||||
if strategy is None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
|
||||
if not steps:
|
||||
# State confirmation (no steps)
|
||||
return strategy.on_state_confirmed() if accepted else strategy.on_state_rejected()
|
||||
# Step-based approval
|
||||
return strategy.on_approval_accepted(steps) if accepted else strategy.on_approval_rejected(steps)
|
||||
|
||||
@@ -11,8 +11,6 @@ from typing import Any
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
EventType,
|
||||
MessagesSnapshotEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateDeltaEvent,
|
||||
@@ -31,9 +29,10 @@ from agent_framework import (
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
from ._utils import generate_event_id
|
||||
from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +47,8 @@ class AgentFrameworkEventBridge:
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
current_state: dict[str, Any] | None = None,
|
||||
skip_text_content: bool = False,
|
||||
input_messages: list[Any] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
approval_tool_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the event bridge.
|
||||
@@ -61,7 +60,6 @@ class AgentFrameworkEventBridge:
|
||||
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
|
||||
current_state: Reference to the current state dict for tracking updates.
|
||||
skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs).
|
||||
input_messages: The input messages from the conversation history.
|
||||
require_confirmation: Whether predictive state updates require user confirmation.
|
||||
"""
|
||||
self.run_id = run_id
|
||||
@@ -74,6 +72,7 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls
|
||||
self.skip_text_content = skip_text_content
|
||||
self.require_confirmation = require_confirmation
|
||||
self.approval_tool_name = approval_tool_name
|
||||
|
||||
# For predictive state updates: accumulate streaming arguments
|
||||
self.streaming_tool_args: str = "" # Accumulated JSON string
|
||||
@@ -82,13 +81,6 @@ class AgentFrameworkEventBridge:
|
||||
self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes
|
||||
self.suppressed_summary: str = "" # Store LLM summary to show after confirmation
|
||||
|
||||
# For MessagesSnapshotEvent: track tool calls and results
|
||||
self.input_messages = input_messages or []
|
||||
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
|
||||
self.tool_results: list[dict[str, Any]] = [] # Track tool results
|
||||
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
|
||||
self.accumulated_text_content: str = "" # Track accumulated text for final MessagesSnapshotEvent
|
||||
|
||||
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
|
||||
"""
|
||||
Convert an AgentRunResponseUpdate to AG-UI events.
|
||||
@@ -154,7 +146,6 @@ class AgentFrameworkEventBridge:
|
||||
message_id=self.current_message_id,
|
||||
delta=content.text,
|
||||
)
|
||||
self.accumulated_text_content += content.text
|
||||
logger.info(f" EMITTING TextMessageContentEvent with text_len={len(content.text)}")
|
||||
events.append(event)
|
||||
return events
|
||||
@@ -169,10 +160,10 @@ class AgentFrameworkEventBridge:
|
||||
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
|
||||
|
||||
tool_call_id = self._coalesce_tool_call_id(content)
|
||||
# Only emit ToolCallStartEvent once per tool call (when it's a new tool call)
|
||||
if content.name and tool_call_id != self.current_tool_call_id:
|
||||
self.streaming_tool_args = ""
|
||||
self.state_delta_count = 0
|
||||
if content.name:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
self.current_tool_call_name = content.name
|
||||
|
||||
@@ -183,17 +174,6 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
|
||||
events.append(tool_start_event)
|
||||
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": content.name,
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
elif tool_call_id:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
|
||||
@@ -206,13 +186,7 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
events.append(args_event)
|
||||
|
||||
for tool_call in self.pending_tool_calls:
|
||||
if tool_call["id"] == tool_call_id:
|
||||
tool_call["function"]["arguments"] += delta_str
|
||||
break
|
||||
|
||||
events.extend(self._emit_predictive_state_deltas(delta_str))
|
||||
events.extend(self._legacy_predictive_state(content))
|
||||
|
||||
return events
|
||||
|
||||
@@ -235,10 +209,8 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_name,
|
||||
)
|
||||
|
||||
parsed_args = None
|
||||
try:
|
||||
parsed_args = json.loads(self.streaming_tool_args)
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = safe_json_parse(self.streaming_tool_args)
|
||||
if parsed_args is None:
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != self.current_tool_call_name:
|
||||
continue
|
||||
@@ -282,11 +254,8 @@ class AgentFrameworkEventBridge:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
|
||||
if state_value is None:
|
||||
continue
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
|
||||
@@ -317,59 +286,6 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
return events
|
||||
|
||||
def _legacy_predictive_state(self, content: FunctionCallContent) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if not (content.name and content.arguments):
|
||||
return events
|
||||
parsed_args = content.parse_arguments()
|
||||
if not parsed_args:
|
||||
return events
|
||||
|
||||
logger.info(
|
||||
"Checking predict_state_config keys: %s",
|
||||
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
|
||||
)
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
logger.info(f"Checking state_key='{state_key}'")
|
||||
if config["tool"] != content.name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
logger.info(f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'")
|
||||
|
||||
state_value: Any
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
logger.info(f"Using all args as state value, keys: {list(state_value.keys())}")
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
logger.info(f"Using specific arg '{tool_arg_name}' as state value")
|
||||
else:
|
||||
logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
previous_value = self.last_emitted_state.get(state_key, object())
|
||||
if previous_value == state_value:
|
||||
logger.info(
|
||||
"Skipping duplicate StateDeltaEvent for key '%s' - value unchanged",
|
||||
state_key,
|
||||
)
|
||||
continue
|
||||
|
||||
state_delta_event = StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/{state_key}",
|
||||
"value": state_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
logger.info(f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}") # type: ignore
|
||||
events.append(state_delta_event)
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
self.last_emitted_state[state_key] = state_value
|
||||
return events
|
||||
|
||||
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if content.call_id:
|
||||
@@ -378,7 +294,6 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.call_id)
|
||||
|
||||
if self.state_delta_count > 0:
|
||||
logger.info(
|
||||
@@ -391,12 +306,7 @@ class AgentFrameworkEventBridge:
|
||||
self.state_delta_count = 0
|
||||
|
||||
result_message_id = generate_event_id()
|
||||
if isinstance(content.result, dict):
|
||||
result_content = json.dumps(content.result) # type: ignore[arg-type]
|
||||
elif content.result is not None:
|
||||
result_content = str(content.result)
|
||||
else:
|
||||
result_content = ""
|
||||
result_content = prepare_function_call_results(content.result)
|
||||
|
||||
result_event = ToolCallResultEvent(
|
||||
message_id=result_message_id,
|
||||
@@ -405,55 +315,10 @@ class AgentFrameworkEventBridge:
|
||||
role="tool",
|
||||
)
|
||||
events.append(result_event)
|
||||
|
||||
self.tool_results.append(
|
||||
{
|
||||
"id": result_message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
events.extend(self._emit_snapshot_for_tool_result())
|
||||
events.extend(self._emit_state_snapshot_and_confirmation())
|
||||
|
||||
return events
|
||||
|
||||
def _emit_snapshot_for_tool_result(self) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
should_emit_snapshot = self.pending_tool_calls and self.tool_results
|
||||
|
||||
is_predictive_without_confirmation = False
|
||||
if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config:
|
||||
for _, config in self.predict_state_config.items():
|
||||
if config["tool"] == self.current_tool_call_name and not self.require_confirmation:
|
||||
is_predictive_without_confirmation = True
|
||||
logger.info(
|
||||
"Skipping intermediate MessagesSnapshotEvent for predictive tool '%s' - delaying until summary",
|
||||
self.current_tool_call_name,
|
||||
)
|
||||
break
|
||||
|
||||
if should_emit_snapshot and not is_predictive_without_confirmation:
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(),
|
||||
}
|
||||
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
|
||||
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
|
||||
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT,
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
)
|
||||
logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
|
||||
events.append(messages_snapshot_event)
|
||||
return events
|
||||
|
||||
def _emit_state_snapshot_and_confirmation(self) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if self.pending_state_updates:
|
||||
@@ -502,31 +367,46 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_name = None
|
||||
return events
|
||||
|
||||
def _emit_confirm_changes_tool_call(self) -> list[BaseEvent]:
|
||||
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
|
||||
"""Emit a confirm_changes tool call for Dojo UI compatibility.
|
||||
|
||||
Args:
|
||||
function_call: Optional function call that needs confirmation.
|
||||
If provided, includes function info in the confirm_changes args
|
||||
so Dojo UI can display what's being confirmed.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
confirm_call_id = generate_event_id()
|
||||
logger.info("Emitting confirm_changes tool call for predictive update")
|
||||
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": confirm_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "confirm_changes",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
confirm_start = ToolCallStartEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
tool_call_name="confirm_changes",
|
||||
parent_message_id=self.current_message_id,
|
||||
)
|
||||
events.append(confirm_start)
|
||||
|
||||
# Include function info if this is for a function approval
|
||||
# This helps Dojo UI display meaningful confirmation info
|
||||
if function_call:
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
"status": "enabled",
|
||||
}
|
||||
],
|
||||
}
|
||||
args_json = json.dumps(args_dict)
|
||||
else:
|
||||
args_json = "{}"
|
||||
|
||||
confirm_args = ToolCallArgsEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
delta="{}",
|
||||
delta=args_json,
|
||||
)
|
||||
events.append(confirm_args)
|
||||
|
||||
@@ -535,23 +415,48 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
events.append(confirm_end)
|
||||
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
return events
|
||||
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(),
|
||||
}
|
||||
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
|
||||
"""Emit a tool call that can drive UI approval for function requests."""
|
||||
tool_call_name = "confirm_changes"
|
||||
if self.approval_tool_name and self.approval_tool_name != function_call.name:
|
||||
tool_call_name = self.approval_tool_name
|
||||
|
||||
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
|
||||
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
|
||||
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT,
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
tool_call_id = generate_event_id()
|
||||
tool_start = ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=tool_call_name,
|
||||
parent_message_id=self.current_message_id,
|
||||
)
|
||||
events: list[BaseEvent] = [tool_start]
|
||||
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
"status": "enabled",
|
||||
}
|
||||
],
|
||||
}
|
||||
args_json = json.dumps(args_dict)
|
||||
|
||||
events.append(
|
||||
ToolCallArgsEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
delta=args_json,
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
ToolCallEndEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
)
|
||||
logger.info(f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages")
|
||||
events.append(messages_snapshot_event)
|
||||
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
@@ -583,12 +488,8 @@ class AgentFrameworkEventBridge:
|
||||
tool_arg_name,
|
||||
)
|
||||
|
||||
state_value: Any
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
|
||||
if state_value is None:
|
||||
logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
@@ -605,8 +506,8 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.function_call.call_id)
|
||||
|
||||
# Emit the function_approval_request custom event for UI implementations that support it
|
||||
approval_event = CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
@@ -620,6 +521,14 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
|
||||
events.append(approval_event)
|
||||
|
||||
# Emit a UI-friendly approval tool call for function approvals.
|
||||
if self.require_confirmation:
|
||||
events.extend(self._emit_function_approval_tool_call(content.function_call))
|
||||
|
||||
# Signal orchestrator to stop the run and wait for user approval response
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run - waiting for function approval response")
|
||||
return events
|
||||
|
||||
def create_run_started_event(self) -> RunStartedEvent:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
"""Message format conversion between AG-UI and Agent Framework."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -11,20 +13,229 @@ from agent_framework import (
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
# Role mapping constants
|
||||
_AGUI_TO_FRAMEWORK_ROLE = {
|
||||
"user": Role.USER,
|
||||
"assistant": Role.ASSISTANT,
|
||||
"system": Role.SYSTEM,
|
||||
}
|
||||
from ._utils import (
|
||||
AGUI_TO_FRAMEWORK_ROLE,
|
||||
FRAMEWORK_TO_AGUI_ROLE,
|
||||
get_role_value,
|
||||
normalize_agui_role,
|
||||
safe_json_parse,
|
||||
)
|
||||
|
||||
_FRAMEWORK_TO_AGUI_ROLE = {
|
||||
Role.USER: "user",
|
||||
Role.ASSISTANT: "assistant",
|
||||
Role.SYSTEM: "system",
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
|
||||
sanitized: list[ChatMessage] = []
|
||||
pending_tool_call_ids: set[str] | None = None
|
||||
pending_confirm_changes_id: str | None = None
|
||||
|
||||
for msg in messages:
|
||||
role_value = get_role_value(msg)
|
||||
|
||||
if role_value == "assistant":
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
}
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = tool_ids if tool_ids else None
|
||||
pending_confirm_changes_id = (
|
||||
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
|
||||
)
|
||||
continue
|
||||
|
||||
if role_value == "user":
|
||||
approval_call_ids: set[str] = set()
|
||||
approval_accepted: bool | None = None
|
||||
for content in msg.contents or []:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if content.function_call and content.function_call.call_id:
|
||||
approval_call_ids.add(str(content.function_call.call_id))
|
||||
if approval_accepted is None:
|
||||
approval_accepted = bool(content.approved)
|
||||
else:
|
||||
approval_accepted = approval_accepted and bool(content.approved)
|
||||
|
||||
if approval_call_ids and pending_tool_call_ids:
|
||||
pending_tool_call_ids -= approval_call_ids
|
||||
logger.info(
|
||||
f"FunctionApprovalResponseContent found for call_ids={sorted(approval_call_ids)} - "
|
||||
"framework will handle execution"
|
||||
)
|
||||
|
||||
if pending_confirm_changes_id and approval_accepted is not None:
|
||||
logger.info(f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if approval_accepted else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
if pending_confirm_changes_id:
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
if "accepted" in parsed:
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
)
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
logger.debug(f"Could not parse user message as confirm_changes response: {type(exc).__name__}")
|
||||
|
||||
if pending_tool_call_ids:
|
||||
logger.info(
|
||||
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - "
|
||||
"injecting synthetic results"
|
||||
)
|
||||
for pending_call_id in pending_tool_call_ids:
|
||||
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_call_id,
|
||||
result="Tool execution skipped - user provided follow-up message",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
|
||||
if role_value == "tool":
|
||||
if not pending_tool_call_ids:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
if call_id == pending_confirm_changes_id:
|
||||
pending_confirm_changes_id = None
|
||||
break
|
||||
if keep:
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove duplicate messages while preserving order."""
|
||||
seen_keys: dict[Any, int] = {}
|
||||
unique_messages: list[ChatMessage] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role_value = get_role_value(msg)
|
||||
|
||||
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
if key in seen_keys:
|
||||
existing_idx = seen_keys[key]
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
if (not existing_result or existing_result == "") and new_result:
|
||||
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
|
||||
unique_messages[existing_idx] = msg
|
||||
else:
|
||||
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
elif (
|
||||
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
|
||||
):
|
||||
tool_call_ids = tuple(
|
||||
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
return unique_messages
|
||||
|
||||
|
||||
def normalize_agui_input_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[list[ChatMessage], list[dict[str, Any]]]:
|
||||
"""Normalize raw AG-UI messages into provider and snapshot formats."""
|
||||
provider_messages = agui_messages_to_agent_framework(messages)
|
||||
provider_messages = _sanitize_tool_history(provider_messages)
|
||||
provider_messages = _deduplicate_messages(provider_messages)
|
||||
snapshot_messages = agui_messages_to_snapshot_format(messages)
|
||||
return provider_messages, snapshot_messages
|
||||
|
||||
|
||||
def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]:
|
||||
@@ -36,11 +247,108 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
Returns:
|
||||
List of Agent Framework ChatMessage objects
|
||||
"""
|
||||
|
||||
def _update_tool_call_arguments(
|
||||
raw_messages: list[dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
modified_args: dict[str, Any],
|
||||
) -> None:
|
||||
for raw_msg in raw_messages:
|
||||
tool_calls = raw_msg.get("tool_calls") or raw_msg.get("toolCalls")
|
||||
if not isinstance(tool_calls, list):
|
||||
continue
|
||||
tool_calls_list = cast(list[Any], tool_calls)
|
||||
for tool_call in tool_calls_list:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_call_dict = cast(dict[str, Any], tool_call)
|
||||
if str(tool_call_dict.get("id", "")) != tool_call_id:
|
||||
continue
|
||||
function_payload = tool_call_dict.get("function")
|
||||
if not isinstance(function_payload, dict):
|
||||
return
|
||||
function_payload_dict = cast(dict[str, Any], function_payload)
|
||||
existing_args = function_payload_dict.get("arguments")
|
||||
if isinstance(existing_args, str):
|
||||
function_payload_dict["arguments"] = json.dumps(modified_args)
|
||||
else:
|
||||
function_payload_dict["arguments"] = modified_args
|
||||
return
|
||||
|
||||
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
|
||||
for prev_msg in result:
|
||||
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
|
||||
if role_val != "assistant":
|
||||
continue
|
||||
for content in prev_msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
if content.call_id == call_id and content.name != "confirm_changes":
|
||||
return content
|
||||
return None
|
||||
|
||||
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
|
||||
return safe_json_parse(arguments)
|
||||
|
||||
def _resolve_approval_call_id(tool_call_id: str, parsed_payload: dict[str, Any] | None) -> str | None:
|
||||
if parsed_payload:
|
||||
explicit_call_id = parsed_payload.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
for prev_msg in result:
|
||||
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
|
||||
if role_val != "assistant":
|
||||
continue
|
||||
direct_call = None
|
||||
confirm_call = None
|
||||
sibling_calls: list[FunctionCallContent] = []
|
||||
for content in prev_msg.contents or []:
|
||||
if not isinstance(content, FunctionCallContent):
|
||||
continue
|
||||
if content.call_id == tool_call_id:
|
||||
direct_call = content
|
||||
if content.name == "confirm_changes" and content.call_id == tool_call_id:
|
||||
confirm_call = content
|
||||
elif content.name != "confirm_changes":
|
||||
sibling_calls.append(content)
|
||||
|
||||
if direct_call:
|
||||
direct_args = direct_call.parse_arguments() or {}
|
||||
if isinstance(direct_args, dict):
|
||||
explicit_call_id = direct_args.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
if not confirm_call:
|
||||
continue
|
||||
|
||||
confirm_args = confirm_call.parse_arguments() or {}
|
||||
if isinstance(confirm_args, dict):
|
||||
explicit_call_id = confirm_args.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
if len(sibling_calls) == 1 and sibling_calls[0].call_id:
|
||||
return str(sibling_calls[0].call_id)
|
||||
|
||||
return None
|
||||
|
||||
def _filter_modified_args(
|
||||
modified_args: dict[str, Any],
|
||||
original_args: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not modified_args:
|
||||
return {}
|
||||
if not isinstance(original_args, dict) or not original_args:
|
||||
return {}
|
||||
allowed_keys = set(original_args.keys())
|
||||
return {key: value for key, value in modified_args.items() if key in allowed_keys}
|
||||
|
||||
result: list[ChatMessage] = []
|
||||
for msg in messages:
|
||||
# Handle standard tool result messages early (role="tool") to preserve provider invariants
|
||||
# This path maps AG‑UI tool messages to FunctionResultContent with the correct tool_call_id
|
||||
role_str = msg.get("role", "user")
|
||||
role_str = normalize_agui_role(msg.get("role", "user"))
|
||||
if role_str == "tool":
|
||||
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
@@ -57,31 +365,153 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
result_content = msg.get("result", "")
|
||||
|
||||
# Distinguish approval payloads from actual tool results
|
||||
is_approval = False
|
||||
parsed: dict[str, Any] | None = None
|
||||
if isinstance(result_content, str) and result_content:
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
parsed = _json.loads(result_content)
|
||||
is_approval = isinstance(parsed, dict) and "accepted" in parsed
|
||||
parsed_candidate = json.loads(result_content)
|
||||
except Exception:
|
||||
is_approval = False
|
||||
parsed_candidate = None
|
||||
if isinstance(parsed_candidate, dict):
|
||||
parsed = cast(dict[str, Any], parsed_candidate)
|
||||
elif isinstance(result_content, dict):
|
||||
parsed = cast(dict[str, Any], result_content)
|
||||
|
||||
is_approval = parsed is not None and "accepted" in parsed
|
||||
|
||||
if is_approval:
|
||||
# Approval responses should be treated as user messages to trigger human-in-the-loop flow
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=str(result_content))],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
# Look for the matching function call in previous messages to create
|
||||
# a proper FunctionApprovalResponseContent. This enables the agent framework
|
||||
# to execute the approved tool (fix for GitHub issue #3034).
|
||||
accepted = parsed.get("accepted", False) if parsed is not None else False
|
||||
approval_payload_text = result_content if isinstance(result_content, str) else json.dumps(parsed)
|
||||
|
||||
# Log the full approval payload to debug modified arguments
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Approval payload received: {parsed}")
|
||||
|
||||
approval_call_id = tool_call_id
|
||||
resolved_call_id = _resolve_approval_call_id(tool_call_id, parsed)
|
||||
if resolved_call_id:
|
||||
approval_call_id = resolved_call_id
|
||||
matching_func_call = _find_matching_func_call(approval_call_id)
|
||||
|
||||
if matching_func_call:
|
||||
# Remove any existing tool result for this call_id since the framework
|
||||
# will re-execute the tool after approval. Keeping old results causes
|
||||
# OpenAI API errors ("tool message must follow assistant with tool_calls").
|
||||
result = [
|
||||
m
|
||||
for m in result
|
||||
if not (
|
||||
(m.role.value if hasattr(m.role, "value") else str(m.role)) == "tool"
|
||||
and any(
|
||||
isinstance(c, FunctionResultContent) and c.call_id == approval_call_id
|
||||
for c in (m.contents or [])
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
# Check if the approval payload contains modified arguments
|
||||
# The UI sends back the modified state (e.g., deselected steps) in the approval payload
|
||||
modified_args = {k: v for k, v in parsed.items() if k != "accepted"} if parsed else {}
|
||||
original_args = matching_func_call.parse_arguments()
|
||||
filtered_args = _filter_modified_args(modified_args, original_args)
|
||||
state_args: dict[str, Any] | None = None
|
||||
if filtered_args:
|
||||
original_args = original_args or {}
|
||||
merged_args: dict[str, Any]
|
||||
if isinstance(original_args, dict) and original_args:
|
||||
merged_args = {**original_args, **filtered_args}
|
||||
else:
|
||||
merged_args = dict(filtered_args)
|
||||
|
||||
if isinstance(filtered_args.get("steps"), list):
|
||||
original_steps = original_args.get("steps") if isinstance(original_args, dict) else None
|
||||
if isinstance(original_steps, list):
|
||||
approved_steps_list = list(filtered_args.get("steps") or [])
|
||||
approved_by_description: dict[str, dict[str, Any]] = {}
|
||||
for step_item in approved_steps_list:
|
||||
if isinstance(step_item, dict):
|
||||
step_item_dict = cast(dict[str, Any], step_item)
|
||||
desc = step_item_dict.get("description")
|
||||
if desc:
|
||||
approved_by_description[str(desc)] = step_item_dict
|
||||
merged_steps: list[Any] = []
|
||||
original_steps_list = cast(list[Any], original_steps)
|
||||
for orig_step in original_steps_list:
|
||||
if not isinstance(orig_step, dict):
|
||||
merged_steps.append(orig_step)
|
||||
continue
|
||||
orig_step_dict = cast(dict[str, Any], orig_step)
|
||||
description = str(orig_step_dict.get("description", ""))
|
||||
approved_step = approved_by_description.get(description)
|
||||
status: str = (
|
||||
str(approved_step.get("status"))
|
||||
if approved_step is not None and approved_step.get("status")
|
||||
else "disabled"
|
||||
)
|
||||
updated_step: dict[str, Any] = orig_step_dict.copy()
|
||||
updated_step["status"] = status
|
||||
merged_steps.append(updated_step)
|
||||
merged_args["steps"] = merged_steps
|
||||
state_args = merged_args
|
||||
|
||||
# Keep the original tool call and AG-UI snapshot in sync with approved args.
|
||||
updated_args = (
|
||||
json.dumps(merged_args) if isinstance(matching_func_call.arguments, str) else merged_args
|
||||
)
|
||||
matching_func_call.arguments = updated_args
|
||||
_update_tool_call_arguments(messages, str(approval_call_id), merged_args)
|
||||
# Create a new FunctionCallContent with the modified arguments
|
||||
func_call_for_approval = FunctionCallContent(
|
||||
call_id=matching_func_call.call_id,
|
||||
name=matching_func_call.name,
|
||||
arguments=json.dumps(filtered_args),
|
||||
)
|
||||
logger.info(f"Using modified arguments from approval: {filtered_args}")
|
||||
else:
|
||||
# No modified arguments - use the original function call
|
||||
func_call_for_approval = matching_func_call
|
||||
|
||||
# Create FunctionApprovalResponseContent for the agent framework
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approved=accepted,
|
||||
id=str(approval_call_id),
|
||||
function_call=func_call_for_approval,
|
||||
additional_properties={"ag_ui_state_args": state_args} if state_args else None,
|
||||
)
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[approval_response],
|
||||
)
|
||||
else:
|
||||
# No matching function call found - this is likely a confirm_changes approval
|
||||
# Keep the old behavior for backwards compatibility
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=approval_payload_text)],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
# Cast result_content to acceptable type for FunctionResultContent
|
||||
func_result: str | dict[str, Any] | list[Any]
|
||||
if isinstance(result_content, str):
|
||||
func_result = result_content
|
||||
elif isinstance(result_content, dict):
|
||||
func_result = cast(dict[str, Any], result_content)
|
||||
elif isinstance(result_content, list):
|
||||
func_result = cast(list[Any], result_content)
|
||||
else:
|
||||
func_result = str(result_content)
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
@@ -142,7 +572,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
|
||||
# No special handling required for assistant/plain messages here
|
||||
|
||||
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
role = AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
|
||||
# Check if this message contains function approvals
|
||||
if "function_approvals" in msg and msg["function_approvals"]:
|
||||
@@ -198,6 +628,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
if isinstance(msg, dict):
|
||||
# Always work on a copy to avoid mutating input
|
||||
normalized_msg = msg.copy()
|
||||
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
|
||||
# Ensure ID exists
|
||||
if "id" not in normalized_msg:
|
||||
normalized_msg["id"] = generate_event_id()
|
||||
@@ -214,7 +645,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
continue
|
||||
|
||||
# Convert ChatMessage to AG-UI format
|
||||
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
|
||||
role = FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
|
||||
|
||||
content_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
@@ -237,13 +668,8 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
# Tool result content - extract call_id and result
|
||||
tool_result_call_id = content.call_id
|
||||
# Serialize result to string
|
||||
if isinstance(content.result, dict):
|
||||
import json
|
||||
|
||||
content_text = json.dumps(content.result) # type: ignore
|
||||
elif content.result is not None:
|
||||
content_text = str(content.result)
|
||||
# Serialize result to string using core utility
|
||||
content_text = prepare_function_call_results(content.result)
|
||||
|
||||
agui_msg: dict[str, Any] = {
|
||||
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
|
||||
@@ -308,22 +734,44 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
|
||||
content = normalized_msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Convert content array format to simple string
|
||||
text_parts = []
|
||||
for item in content:
|
||||
text_parts: list[str] = []
|
||||
content_list = cast(list[Any], content)
|
||||
for item in content_list:
|
||||
if isinstance(item, dict):
|
||||
item_dict = cast(dict[str, Any], item)
|
||||
# Convert 'input_text' to 'text' type
|
||||
if item.get("type") == "input_text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
if item_dict.get("type") == "input_text":
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
elif item_dict.get("type") == "text":
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
else:
|
||||
# Other types - just extract text field if present
|
||||
text_parts.append(item.get("text", ""))
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
normalized_msg["content"] = "".join(text_parts)
|
||||
elif content is None:
|
||||
normalized_msg["content"] = ""
|
||||
|
||||
tool_calls = normalized_msg.get("tool_calls") or normalized_msg.get("toolCalls")
|
||||
if isinstance(tool_calls, list):
|
||||
tool_calls_list = cast(list[Any], tool_calls)
|
||||
for tool_call in tool_calls_list:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_call_dict = cast(dict[str, Any], tool_call)
|
||||
function_payload = tool_call_dict.get("function")
|
||||
if not isinstance(function_payload, dict):
|
||||
continue
|
||||
function_payload_dict = cast(dict[str, Any], function_payload)
|
||||
if "arguments" not in function_payload_dict:
|
||||
continue
|
||||
arguments = function_payload_dict.get("arguments")
|
||||
if arguments is None:
|
||||
function_payload_dict["arguments"] = ""
|
||||
elif not isinstance(arguments, str):
|
||||
function_payload_dict["arguments"] = json.dumps(arguments)
|
||||
|
||||
# Normalize tool_call_id to toolCallId for tool messages
|
||||
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
|
||||
if normalized_msg.get("role") == "tool":
|
||||
if "tool_call_id" in normalized_msg:
|
||||
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Helper functions for orchestration logic."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from .._utils import get_role_value, safe_json_parse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._events import AgentFrameworkEventBridge
|
||||
from ._state_manager import StateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
|
||||
"""Get IDs of tool calls without corresponding results.
|
||||
|
||||
Args:
|
||||
messages: List of messages to scan
|
||||
|
||||
Returns:
|
||||
Set of pending tool call IDs
|
||||
"""
|
||||
pending_ids: set[str] = set()
|
||||
resolved_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.call_id:
|
||||
pending_ids.add(str(content.call_id))
|
||||
elif isinstance(content, FunctionResultContent) and content.call_id:
|
||||
resolved_ids.add(str(content.call_id))
|
||||
return pending_ids - resolved_ids
|
||||
|
||||
|
||||
def is_state_context_message(message: ChatMessage) -> bool:
|
||||
"""Check if a message is a state context system message.
|
||||
|
||||
Args:
|
||||
message: Message to check
|
||||
|
||||
Returns:
|
||||
True if this is a state context message
|
||||
"""
|
||||
if get_role_value(message) != "system":
|
||||
return False
|
||||
for content in message.contents:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ensure_tool_call_entry(
|
||||
tool_call_id: str,
|
||||
tool_calls_by_id: dict[str, dict[str, Any]],
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Get or create a tool call entry in the tracking dicts.
|
||||
|
||||
Args:
|
||||
tool_call_id: The tool call ID
|
||||
tool_calls_by_id: Dict mapping IDs to tool call entries
|
||||
pending_tool_calls: List of pending tool calls
|
||||
|
||||
Returns:
|
||||
The tool call entry dict
|
||||
"""
|
||||
entry = tool_calls_by_id.get(tool_call_id)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
tool_calls_by_id[tool_call_id] = entry
|
||||
pending_tool_calls.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def tool_name_for_call_id(
|
||||
tool_calls_by_id: dict[str, dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
) -> str | None:
|
||||
"""Get the tool name for a given call ID.
|
||||
|
||||
Args:
|
||||
tool_calls_by_id: Dict mapping IDs to tool call entries
|
||||
tool_call_id: The tool call ID to look up
|
||||
|
||||
Returns:
|
||||
Tool name or None if not found
|
||||
"""
|
||||
entry = tool_calls_by_id.get(tool_call_id)
|
||||
if not entry:
|
||||
return None
|
||||
function = entry.get("function")
|
||||
if not isinstance(function, dict):
|
||||
return None
|
||||
name = function.get("name")
|
||||
return str(name) if name else None
|
||||
|
||||
|
||||
def tool_calls_match_state(
|
||||
provider_messages: list[ChatMessage],
|
||||
state_manager: "StateManager",
|
||||
) -> bool:
|
||||
"""Check if tool calls in messages match current state.
|
||||
|
||||
Args:
|
||||
provider_messages: Messages to check
|
||||
state_manager: State manager with config and current state
|
||||
|
||||
Returns:
|
||||
True if tool calls match state configuration
|
||||
"""
|
||||
if not state_manager.predict_state_config or not state_manager.current_state:
|
||||
return False
|
||||
|
||||
for state_key, config in state_manager.predict_state_config.items():
|
||||
tool_name = config["tool"]
|
||||
tool_arg_name = config["tool_argument"]
|
||||
tool_args: dict[str, Any] | None = None
|
||||
|
||||
for msg in reversed(provider_messages):
|
||||
if get_role_value(msg) != "assistant":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.name == tool_name:
|
||||
tool_args = safe_json_parse(content.arguments)
|
||||
break
|
||||
if tool_args is not None:
|
||||
break
|
||||
|
||||
if not tool_args:
|
||||
return False
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = tool_args
|
||||
elif tool_arg_name in tool_args:
|
||||
state_value = tool_args[tool_arg_name]
|
||||
else:
|
||||
return False
|
||||
|
||||
if state_manager.current_state.get(state_key) != state_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def schema_has_steps(schema: Any) -> bool:
|
||||
"""Check if a schema has a steps array property.
|
||||
|
||||
Args:
|
||||
schema: JSON schema to check
|
||||
|
||||
Returns:
|
||||
True if schema has steps array
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return False
|
||||
steps_schema = properties.get("steps")
|
||||
if not isinstance(steps_schema, dict):
|
||||
return False
|
||||
return steps_schema.get("type") == "array"
|
||||
|
||||
|
||||
def select_approval_tool_name(client_tools: list[Any] | None) -> str | None:
|
||||
"""Select appropriate approval tool from client tools.
|
||||
|
||||
Args:
|
||||
client_tools: List of client tool definitions
|
||||
|
||||
Returns:
|
||||
Name of approval tool, or None if not found
|
||||
"""
|
||||
if not client_tools:
|
||||
return None
|
||||
for tool in client_tools:
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if not tool_name:
|
||||
continue
|
||||
params_fn = getattr(tool, "parameters", None)
|
||||
if not callable(params_fn):
|
||||
continue
|
||||
schema = params_fn()
|
||||
if schema_has_steps(schema):
|
||||
return str(tool_name)
|
||||
return None
|
||||
|
||||
|
||||
def select_messages_to_run(
|
||||
provider_messages: list[ChatMessage],
|
||||
state_manager: "StateManager",
|
||||
) -> list[ChatMessage]:
|
||||
"""Select and prepare messages for agent execution.
|
||||
|
||||
Injects state context message when appropriate.
|
||||
|
||||
Args:
|
||||
provider_messages: Original messages from client
|
||||
state_manager: State manager instance
|
||||
|
||||
Returns:
|
||||
Messages ready for agent execution
|
||||
"""
|
||||
if not provider_messages:
|
||||
return []
|
||||
|
||||
is_new_user_turn = get_role_value(provider_messages[-1]) == "user"
|
||||
conversation_has_tool_calls = tool_calls_match_state(provider_messages, state_manager)
|
||||
state_context_msg = state_manager.state_context_message(
|
||||
is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls
|
||||
)
|
||||
if not state_context_msg:
|
||||
return list(provider_messages)
|
||||
|
||||
messages_to_run = [msg for msg in provider_messages if not is_state_context_message(msg)]
|
||||
if pending_tool_call_ids(messages_to_run):
|
||||
return messages_to_run
|
||||
|
||||
insert_index = len(messages_to_run) - 1 if is_new_user_turn else len(messages_to_run)
|
||||
if insert_index < 0:
|
||||
insert_index = 0
|
||||
messages_to_run.insert(insert_index, state_context_msg)
|
||||
return messages_to_run
|
||||
|
||||
|
||||
def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build metadata dict with truncated string values.
|
||||
|
||||
Args:
|
||||
thread_metadata: Raw metadata dict
|
||||
|
||||
Returns:
|
||||
Metadata with string values truncated to 512 chars
|
||||
"""
|
||||
if not thread_metadata:
|
||||
return {}
|
||||
safe_metadata: dict[str, Any] = {}
|
||||
for key, value in thread_metadata.items():
|
||||
value_str = value if isinstance(value, str) else json.dumps(value)
|
||||
if len(value_str) > 512:
|
||||
value_str = value_str[:512]
|
||||
safe_metadata[key] = value_str
|
||||
return safe_metadata
|
||||
|
||||
|
||||
def collect_approved_state_snapshots(
|
||||
provider_messages: list[ChatMessage],
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
current_state: dict[str, Any],
|
||||
event_bridge: "AgentFrameworkEventBridge",
|
||||
) -> list[StateSnapshotEvent]:
|
||||
"""Collect state snapshots from approved function calls.
|
||||
|
||||
Args:
|
||||
provider_messages: Messages containing approvals
|
||||
predict_state_config: Predictive state configuration
|
||||
current_state: Current state dict (will be mutated)
|
||||
event_bridge: Event bridge for creating events
|
||||
|
||||
Returns:
|
||||
List of state snapshot events
|
||||
"""
|
||||
if not predict_state_config:
|
||||
return []
|
||||
|
||||
events: list[StateSnapshotEvent] = []
|
||||
for msg in provider_messages:
|
||||
if get_role_value(msg) != "user":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if not content.function_call or not content.approved:
|
||||
continue
|
||||
parsed_args = content.function_call.parse_arguments()
|
||||
state_args = None
|
||||
if content.additional_properties:
|
||||
state_args = content.additional_properties.get("ag_ui_state_args")
|
||||
if not isinstance(state_args, dict):
|
||||
state_args = parsed_args
|
||||
if not state_args:
|
||||
continue
|
||||
for state_key, config in predict_state_config.items():
|
||||
if config["tool"] != content.function_call.name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
if tool_arg_name == "*":
|
||||
state_value = state_args
|
||||
elif isinstance(state_args, dict) and tool_arg_name in state_args:
|
||||
state_value = state_args[tool_arg_name]
|
||||
else:
|
||||
continue
|
||||
current_state[state_key] = state_value
|
||||
event_bridge.current_state[state_key] = state_value
|
||||
logger.info(
|
||||
f"Emitting StateSnapshotEvent for approved state key '{state_key}' "
|
||||
f"with {len(state_value) if isinstance(state_value, list) else 'N/A'} items"
|
||||
)
|
||||
events.append(StateSnapshotEvent(snapshot=current_state))
|
||||
break
|
||||
return events
|
||||
|
||||
|
||||
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
|
||||
"""Get the latest approval response from messages.
|
||||
|
||||
Args:
|
||||
messages: Messages to search
|
||||
|
||||
Returns:
|
||||
Latest approval response or None
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
last_message = messages[-1]
|
||||
for content in last_message.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
|
||||
"""Extract steps from an approval response.
|
||||
|
||||
Args:
|
||||
approval: Approval response content
|
||||
|
||||
Returns:
|
||||
List of steps, or empty list if none
|
||||
"""
|
||||
state_args: Any | None = None
|
||||
if approval.additional_properties:
|
||||
state_args = approval.additional_properties.get("ag_ui_state_args")
|
||||
if isinstance(state_args, dict):
|
||||
steps = state_args.get("steps")
|
||||
if isinstance(steps, list):
|
||||
return steps
|
||||
|
||||
if approval.function_call:
|
||||
parsed_args = approval.function_call.parse_arguments()
|
||||
if isinstance(parsed_args, dict):
|
||||
steps = parsed_args.get("steps")
|
||||
if isinstance(steps, list):
|
||||
return steps
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def is_step_based_approval(
|
||||
approval: FunctionApprovalResponseContent,
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
) -> bool:
|
||||
"""Check if an approval is step-based.
|
||||
|
||||
Args:
|
||||
approval: Approval response to check
|
||||
predict_state_config: Predictive state configuration
|
||||
|
||||
Returns:
|
||||
True if this is a step-based approval
|
||||
"""
|
||||
steps = approval_steps(approval)
|
||||
if steps:
|
||||
return True
|
||||
if not approval.function_call:
|
||||
return False
|
||||
if not predict_state_config:
|
||||
return False
|
||||
tool_name = approval.function_call.name
|
||||
for config in predict_state_config.values():
|
||||
if config.get("tool") == tool_name and config.get("tool_argument") == "steps":
|
||||
return True
|
||||
return False
|
||||
@@ -1,176 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Message hygiene utilities for orchestrators."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
|
||||
sanitized: list[ChatMessage] = []
|
||||
pending_tool_call_ids: set[str] | None = None
|
||||
pending_confirm_changes_id: str | None = None
|
||||
|
||||
for msg in messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
if role_value == "assistant":
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
}
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = tool_ids if tool_ids else None
|
||||
pending_confirm_changes_id = (
|
||||
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
|
||||
)
|
||||
continue
|
||||
|
||||
if role_value == "user":
|
||||
if pending_confirm_changes_id:
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
if "accepted" in parsed:
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
)
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
logger.debug("Could not parse user message as confirm_changes response: %s", type(exc).__name__)
|
||||
|
||||
if pending_tool_call_ids:
|
||||
logger.info(
|
||||
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results"
|
||||
)
|
||||
for pending_call_id in pending_tool_call_ids:
|
||||
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_call_id,
|
||||
result="Tool execution skipped - user provided follow-up message",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
|
||||
if role_value == "tool":
|
||||
if not pending_tool_call_ids:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
if call_id == pending_confirm_changes_id:
|
||||
pending_confirm_changes_id = None
|
||||
break
|
||||
if keep:
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove duplicate messages while preserving order."""
|
||||
seen_keys: dict[Any, int] = {}
|
||||
unique_messages: list[ChatMessage] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
if key in seen_keys:
|
||||
existing_idx = seen_keys[key]
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
if (not existing_result or existing_result == "") and new_result:
|
||||
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
|
||||
unique_messages[existing_idx] = msg
|
||||
else:
|
||||
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
elif (
|
||||
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
|
||||
):
|
||||
tool_call_ids = tuple(
|
||||
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
return unique_messages
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Predictive state handling utilities."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import StateDeltaEvent
|
||||
|
||||
from .._utils import safe_json_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PredictiveStateHandler:
|
||||
"""Handles predictive state updates from streaming tool calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
current_state: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the handler.
|
||||
|
||||
Args:
|
||||
predict_state_config: Configuration mapping state keys to tool/argument pairs
|
||||
current_state: Reference to current state dict
|
||||
"""
|
||||
self.predict_state_config = predict_state_config or {}
|
||||
self.current_state = current_state or {}
|
||||
self.streaming_tool_args: str = ""
|
||||
self.last_emitted_state: dict[str, Any] = {}
|
||||
self.state_delta_count: int = 0
|
||||
self.pending_state_updates: dict[str, Any] = {}
|
||||
|
||||
def reset_streaming(self) -> None:
|
||||
"""Reset streaming state for a new tool call."""
|
||||
self.streaming_tool_args = ""
|
||||
self.state_delta_count = 0
|
||||
|
||||
def extract_state_value(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: dict[str, Any] | str | None,
|
||||
) -> tuple[str, Any] | None:
|
||||
"""Extract state value from tool arguments based on config.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool being called
|
||||
args: Tool arguments (dict or JSON string)
|
||||
|
||||
Returns:
|
||||
Tuple of (state_key, state_value) or None if no match
|
||||
"""
|
||||
if not self.predict_state_config:
|
||||
return None
|
||||
|
||||
parsed_args = safe_json_parse(args) if isinstance(args, str) else args
|
||||
if not parsed_args:
|
||||
return None
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
if tool_arg_name == "*":
|
||||
return (state_key, parsed_args)
|
||||
if tool_arg_name in parsed_args:
|
||||
return (state_key, parsed_args[tool_arg_name])
|
||||
|
||||
return None
|
||||
|
||||
def is_predictive_tool(self, tool_name: str | None) -> bool:
|
||||
"""Check if a tool is configured for predictive state.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to check
|
||||
|
||||
Returns:
|
||||
True if tool is in predictive state config
|
||||
"""
|
||||
if not tool_name or not self.predict_state_config:
|
||||
return False
|
||||
for config in self.predict_state_config.values():
|
||||
if config["tool"] == tool_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
def emit_streaming_deltas(
|
||||
self,
|
||||
tool_name: str | None,
|
||||
argument_chunk: str,
|
||||
) -> list[StateDeltaEvent]:
|
||||
"""Process streaming argument chunk and emit state deltas.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
argument_chunk: New chunk of JSON arguments
|
||||
|
||||
Returns:
|
||||
List of state delta events to emit
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
if not tool_name or not self.predict_state_config:
|
||||
return events
|
||||
|
||||
self.streaming_tool_args += argument_chunk
|
||||
logger.debug(
|
||||
"Predictive state: accumulated %s chars for tool '%s'",
|
||||
len(self.streaming_tool_args),
|
||||
tool_name,
|
||||
)
|
||||
|
||||
# Try to parse complete JSON first
|
||||
parsed_args = None
|
||||
try:
|
||||
parsed_args = json.loads(self.streaming_tool_args)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to regex matching for partial JSON
|
||||
events.extend(self._emit_partial_deltas(tool_name))
|
||||
|
||||
if parsed_args:
|
||||
events.extend(self._emit_complete_deltas(tool_name, parsed_args))
|
||||
|
||||
return events
|
||||
|
||||
def _emit_partial_deltas(self, tool_name: str) -> list[StateDeltaEvent]:
|
||||
"""Emit deltas from partial JSON using regex matching.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
|
||||
Returns:
|
||||
List of state delta events
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
|
||||
match = re.search(pattern, self.streaming_tool_args)
|
||||
|
||||
if match:
|
||||
partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value:
|
||||
event = self._create_delta_event(state_key, partial_value)
|
||||
events.append(event)
|
||||
self.last_emitted_state[state_key] = partial_value
|
||||
self.pending_state_updates[state_key] = partial_value
|
||||
|
||||
return events
|
||||
|
||||
def _emit_complete_deltas(
|
||||
self,
|
||||
tool_name: str,
|
||||
parsed_args: dict[str, Any],
|
||||
) -> list[StateDeltaEvent]:
|
||||
"""Emit deltas from complete parsed JSON.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
parsed_args: Fully parsed arguments dict
|
||||
|
||||
Returns:
|
||||
List of state delta events
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
continue
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
|
||||
event = self._create_delta_event(state_key, state_value)
|
||||
events.append(event)
|
||||
self.last_emitted_state[state_key] = state_value
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
|
||||
return events
|
||||
|
||||
def _create_delta_event(self, state_key: str, value: Any) -> StateDeltaEvent:
|
||||
"""Create a state delta event with logging.
|
||||
|
||||
Args:
|
||||
state_key: The state key being updated
|
||||
value: The new value
|
||||
|
||||
Returns:
|
||||
StateDeltaEvent instance
|
||||
"""
|
||||
self.state_delta_count += 1
|
||||
if self.state_delta_count % 10 == 1:
|
||||
logger.info(
|
||||
"StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s",
|
||||
self.state_delta_count,
|
||||
state_key,
|
||||
state_key,
|
||||
len(str(value)),
|
||||
)
|
||||
elif self.state_delta_count % 100 == 0:
|
||||
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
|
||||
|
||||
return StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/{state_key}",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def apply_pending_updates(self) -> None:
|
||||
"""Apply pending updates to current state and clear them."""
|
||||
for key, value in self.pending_state_updates.items():
|
||||
self.current_state[key] = value
|
||||
self.pending_state_updates.clear()
|
||||
@@ -22,9 +22,11 @@ class StateManager:
|
||||
self.predict_state_config = predict_state_config or {}
|
||||
self.require_confirmation = require_confirmation
|
||||
self.current_state: dict[str, Any] = {}
|
||||
self._state_from_input: bool = False
|
||||
|
||||
def initialize(self, initial_state: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Initialize state with schema defaults."""
|
||||
self._state_from_input = initial_state is not None
|
||||
self.current_state = (initial_state or {}).copy()
|
||||
self._apply_schema_defaults()
|
||||
return self.current_state
|
||||
@@ -60,7 +62,9 @@ class StateManager:
|
||||
"""Inject state context only when starting a new user turn."""
|
||||
if not self.current_state or not self.state_schema:
|
||||
return None
|
||||
if not is_new_user_turn or conversation_has_tool_calls:
|
||||
if not is_new_user_turn:
|
||||
return None
|
||||
if conversation_has_tool_calls and not self._state_from_input:
|
||||
return None
|
||||
|
||||
state_json = json.dumps(self.current_state, indent=2)
|
||||
|
||||
@@ -16,6 +16,10 @@ from ag_ui.core import (
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
@@ -25,8 +29,31 @@ from agent_framework import (
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework._middleware import extract_and_merge_function_middleware
|
||||
from agent_framework._tools import (
|
||||
FunctionInvocationConfiguration,
|
||||
_collect_approval_responses, # type: ignore
|
||||
_replace_approval_contents_with_results, # type: ignore
|
||||
_try_execute_function_calls, # type: ignore
|
||||
)
|
||||
|
||||
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id
|
||||
from ._orchestration._helpers import (
|
||||
approval_steps,
|
||||
build_safe_metadata,
|
||||
collect_approved_state_snapshots,
|
||||
ensure_tool_call_entry,
|
||||
is_step_based_approval,
|
||||
latest_approval_response,
|
||||
select_approval_tool_name,
|
||||
select_messages_to_run,
|
||||
tool_name_for_call_id,
|
||||
)
|
||||
from ._orchestration._tooling import (
|
||||
collect_server_tools,
|
||||
merge_tools,
|
||||
register_additional_client_tools,
|
||||
)
|
||||
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id, get_role_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._agent import AgentConfig
|
||||
@@ -61,6 +88,7 @@ class ExecutionContext:
|
||||
|
||||
# Lazy-loaded properties
|
||||
self._messages = None
|
||||
self._snapshot_messages = None
|
||||
self._last_message = None
|
||||
self._run_id: str | None = None
|
||||
self._thread_id: str | None = None
|
||||
@@ -69,12 +97,27 @@ class ExecutionContext:
|
||||
def messages(self):
|
||||
"""Get converted Agent Framework messages (lazy loaded)."""
|
||||
if self._messages is None:
|
||||
from ._message_adapters import agui_messages_to_agent_framework
|
||||
from ._message_adapters import normalize_agui_input_messages
|
||||
|
||||
raw = self.input_data.get("messages", [])
|
||||
self._messages = agui_messages_to_agent_framework(raw)
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
self._messages, self._snapshot_messages = normalize_agui_input_messages(raw)
|
||||
return self._messages
|
||||
|
||||
@property
|
||||
def snapshot_messages(self) -> list[dict[str, Any]]:
|
||||
"""Get normalized AG-UI snapshot messages (lazy loaded)."""
|
||||
if self._snapshot_messages is None:
|
||||
if self._messages is None:
|
||||
_ = self.messages
|
||||
else:
|
||||
from ._message_adapters import agent_framework_messages_to_agui, agui_messages_to_snapshot_format
|
||||
|
||||
raw_snapshot = agent_framework_messages_to_agui(self._messages)
|
||||
self._snapshot_messages = agui_messages_to_snapshot_format(raw_snapshot)
|
||||
return self._snapshot_messages or []
|
||||
|
||||
@property
|
||||
def last_message(self):
|
||||
"""Get the last message in the conversation (lazy loaded)."""
|
||||
@@ -270,14 +313,7 @@ class DefaultOrchestrator(Orchestrator):
|
||||
AG-UI events
|
||||
"""
|
||||
from ._events import AgentFrameworkEventBridge
|
||||
from ._message_adapters import agui_messages_to_snapshot_format
|
||||
from ._orchestration._message_hygiene import deduplicate_messages, sanitize_tool_history
|
||||
from ._orchestration._state_manager import StateManager
|
||||
from ._orchestration._tooling import (
|
||||
collect_server_tools,
|
||||
merge_tools,
|
||||
register_additional_client_tools,
|
||||
)
|
||||
|
||||
logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}")
|
||||
|
||||
@@ -286,12 +322,15 @@ class DefaultOrchestrator(Orchestrator):
|
||||
response_format = context.agent.chat_options.response_format
|
||||
skip_text_content = response_format is not None
|
||||
|
||||
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
|
||||
approval_tool_name = select_approval_tool_name(client_tools)
|
||||
|
||||
state_manager = StateManager(
|
||||
state_schema=context.config.state_schema,
|
||||
predict_state_config=context.config.predict_state_config,
|
||||
require_confirmation=context.config.require_confirmation,
|
||||
)
|
||||
current_state = state_manager.initialize(context.input_data.get("state", {}))
|
||||
current_state = state_manager.initialize(context.input_data.get("state"))
|
||||
|
||||
event_bridge = AgentFrameworkEventBridge(
|
||||
run_id=context.run_id,
|
||||
@@ -299,8 +338,8 @@ class DefaultOrchestrator(Orchestrator):
|
||||
predict_state_config=context.config.predict_state_config,
|
||||
current_state=current_state,
|
||||
skip_text_content=skip_text_content,
|
||||
input_messages=context.input_data.get("messages", []),
|
||||
require_confirmation=context.config.require_confirmation,
|
||||
approval_tool_name=approval_tool_name,
|
||||
)
|
||||
|
||||
yield event_bridge.create_run_started_event()
|
||||
@@ -321,17 +360,18 @@ class DefaultOrchestrator(Orchestrator):
|
||||
if current_state:
|
||||
thread.metadata["current_state"] = current_state # type: ignore[attr-defined]
|
||||
|
||||
raw_messages = context.messages or []
|
||||
if not raw_messages:
|
||||
provider_messages = context.messages or []
|
||||
snapshot_messages = context.snapshot_messages
|
||||
if not provider_messages:
|
||||
logger.warning("No messages provided in AG-UI input")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
logger.info(f"Received {len(raw_messages)} raw messages from client")
|
||||
for i, msg in enumerate(raw_messages):
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
logger.info(f"Received {len(provider_messages)} provider messages from client")
|
||||
for i, msg in enumerate(provider_messages):
|
||||
role = get_role_value(msg)
|
||||
msg_id = getattr(msg, "message_id", None)
|
||||
logger.info(f" Raw message {i}: role={role}, id={msg_id}")
|
||||
logger.info(f" Message {i}: role={role}, id={msg_id}")
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for j, content in enumerate(msg.contents):
|
||||
content_type = type(content).__name__
|
||||
@@ -354,62 +394,26 @@ class DefaultOrchestrator(Orchestrator):
|
||||
else:
|
||||
logger.debug(f" Content {j}: {content_type}")
|
||||
|
||||
sanitized_messages = sanitize_tool_history(raw_messages)
|
||||
provider_messages = deduplicate_messages(sanitized_messages)
|
||||
pending_tool_calls: list[dict[str, Any]] = []
|
||||
tool_calls_by_id: dict[str, dict[str, Any]] = {}
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
tool_calls_ended: set[str] = set()
|
||||
messages_snapshot_emitted = False
|
||||
accumulated_text_content = ""
|
||||
active_message_id: str | None = None
|
||||
|
||||
if not provider_messages:
|
||||
logger.info("No provider-eligible messages after filtering; finishing run without invoking agent.")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
# Check for FunctionApprovalResponseContent and emit updated state snapshot
|
||||
# This ensures the UI shows the approved state (e.g., 2 steps) not the original (3 steps)
|
||||
for snapshot_evt in collect_approved_state_snapshots(
|
||||
provider_messages,
|
||||
context.config.predict_state_config,
|
||||
current_state,
|
||||
event_bridge,
|
||||
):
|
||||
yield snapshot_evt
|
||||
|
||||
logger.info(f"Processing {len(provider_messages)} provider messages after sanitization/deduplication")
|
||||
for i, msg in enumerate(provider_messages):
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
logger.info(f" Message {i}: role={role}")
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for j, content in enumerate(msg.contents):
|
||||
content_type = type(content).__name__
|
||||
if isinstance(content, TextContent):
|
||||
logger.info(f" Content {j}: {content_type} - text_length={len(content.text)}")
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
arg_length = len(str(content.arguments)) if content.arguments else 0
|
||||
logger.info(" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length)
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
result_preview = type(content.result).__name__ if content.result is not None else "None"
|
||||
logger.info(
|
||||
" Content %s: %s - call_id=%s, result_type=%s",
|
||||
j,
|
||||
content_type,
|
||||
content.call_id,
|
||||
result_preview,
|
||||
)
|
||||
else:
|
||||
logger.info(f" Content {j}: {content_type}")
|
||||
messages_to_run = select_messages_to_run(provider_messages, state_manager)
|
||||
|
||||
messages_to_run: list[Any] = []
|
||||
is_new_user_turn = False
|
||||
if provider_messages:
|
||||
last_msg = provider_messages[-1]
|
||||
role_value = last_msg.role.value if hasattr(last_msg.role, "value") else str(last_msg.role)
|
||||
is_new_user_turn = role_value == "user"
|
||||
|
||||
conversation_has_tool_calls = False
|
||||
for msg in provider_messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role_value == "assistant" and hasattr(msg, "contents") and msg.contents:
|
||||
if any(isinstance(content, FunctionCallContent) for content in msg.contents):
|
||||
conversation_has_tool_calls = True
|
||||
break
|
||||
|
||||
state_context_msg = state_manager.state_context_message(
|
||||
is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls
|
||||
)
|
||||
if state_context_msg:
|
||||
messages_to_run.append(state_context_msg)
|
||||
|
||||
messages_to_run.extend(provider_messages)
|
||||
|
||||
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
|
||||
logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools")
|
||||
if client_tools:
|
||||
for tool in client_tools:
|
||||
@@ -421,17 +425,11 @@ class DefaultOrchestrator(Orchestrator):
|
||||
register_additional_client_tools(context.agent, client_tools)
|
||||
tools_param = merge_tools(server_tools, client_tools)
|
||||
|
||||
all_updates: list[Any] = []
|
||||
collect_updates = response_format is not None
|
||||
all_updates: list[Any] | None = [] if collect_updates else None
|
||||
update_count = 0
|
||||
# Prepare metadata for chat client (Azure requires string values)
|
||||
safe_metadata: dict[str, Any] = {}
|
||||
thread_metadata = getattr(thread, "metadata", None)
|
||||
if thread_metadata:
|
||||
for key, value in thread_metadata.items():
|
||||
value_str = value if isinstance(value, str) else json.dumps(value)
|
||||
if len(value_str) > 512:
|
||||
value_str = value_str[:512]
|
||||
safe_metadata[key] = value_str
|
||||
safe_metadata = build_safe_metadata(getattr(thread, "metadata", None))
|
||||
|
||||
run_kwargs: dict[str, Any] = {
|
||||
"thread": thread,
|
||||
@@ -441,27 +439,200 @@ class DefaultOrchestrator(Orchestrator):
|
||||
if safe_metadata:
|
||||
run_kwargs["store"] = True
|
||||
|
||||
async def _resolve_approval_responses(
|
||||
messages: list[Any],
|
||||
tools_for_execution: list[Any],
|
||||
) -> None:
|
||||
fcc_todo = _collect_approval_responses(messages)
|
||||
if not fcc_todo:
|
||||
return
|
||||
|
||||
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
|
||||
approved_function_results: list[Any] = []
|
||||
if approved_responses and tools_for_execution:
|
||||
chat_client = getattr(context.agent, "chat_client", None)
|
||||
config = (
|
||||
getattr(chat_client, "function_invocation_configuration", None) or FunctionInvocationConfiguration()
|
||||
)
|
||||
middleware_pipeline = extract_and_merge_function_middleware(chat_client, run_kwargs)
|
||||
try:
|
||||
results, _ = await _try_execute_function_calls(
|
||||
custom_args=run_kwargs,
|
||||
attempt_idx=0,
|
||||
function_calls=approved_responses,
|
||||
tools=tools_for_execution,
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
config=config,
|
||||
)
|
||||
approved_function_results = list(results)
|
||||
except Exception:
|
||||
logger.error("Failed to execute approved tool calls; injecting error results.")
|
||||
approved_function_results = []
|
||||
|
||||
normalized_results: list[FunctionResultContent] = []
|
||||
for idx, approval in enumerate(approved_responses):
|
||||
if idx < len(approved_function_results) and isinstance(
|
||||
approved_function_results[idx], FunctionResultContent
|
||||
):
|
||||
normalized_results.append(approved_function_results[idx])
|
||||
continue
|
||||
call_id = approval.function_call.call_id or approval.id
|
||||
normalized_results.append(
|
||||
FunctionResultContent(call_id=call_id, result="Error: Tool call invocation failed.")
|
||||
)
|
||||
|
||||
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
|
||||
|
||||
def _should_emit_tool_snapshot(tool_name: str | None) -> bool:
|
||||
if not pending_tool_calls or not tool_results:
|
||||
return False
|
||||
if tool_name and context.config.predict_state_config and not context.config.require_confirmation:
|
||||
for config in context.config.predict_state_config.values():
|
||||
if config["tool"] == tool_name:
|
||||
logger.info(
|
||||
f"Skipping intermediate MessagesSnapshotEvent for predictive tool '{tool_name}' "
|
||||
" - delaying until summary"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _build_messages_snapshot(tool_message_id: str | None = None) -> MessagesSnapshotEvent:
|
||||
has_text_content = bool(accumulated_text_content)
|
||||
all_messages = snapshot_messages.copy()
|
||||
|
||||
if pending_tool_calls:
|
||||
if tool_message_id and not has_text_content:
|
||||
tool_call_message_id = tool_message_id
|
||||
else:
|
||||
tool_call_message_id = (
|
||||
active_message_id if not has_text_content and active_message_id else generate_event_id()
|
||||
)
|
||||
tool_call_message = {
|
||||
"id": tool_call_message_id,
|
||||
"role": "assistant",
|
||||
"tool_calls": pending_tool_calls.copy(),
|
||||
}
|
||||
all_messages.append(tool_call_message)
|
||||
|
||||
all_messages.extend(tool_results)
|
||||
|
||||
if has_text_content and active_message_id:
|
||||
assistant_text_message = {
|
||||
"id": active_message_id,
|
||||
"role": "assistant",
|
||||
"content": accumulated_text_content,
|
||||
}
|
||||
all_messages.append(assistant_text_message)
|
||||
|
||||
return MessagesSnapshotEvent(
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Use tools_param if available (includes client tools), otherwise fall back to server_tools
|
||||
# This ensures both server tools AND client tools can be executed after approval
|
||||
tools_for_approval = tools_param if tools_param is not None else server_tools
|
||||
latest_approval = latest_approval_response(messages_to_run)
|
||||
await _resolve_approval_responses(messages_to_run, tools_for_approval)
|
||||
|
||||
if latest_approval and is_step_based_approval(latest_approval, context.config.predict_state_config):
|
||||
from ._confirmation_strategies import DefaultConfirmationStrategy
|
||||
|
||||
strategy = context.confirmation_strategy
|
||||
if strategy is None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
|
||||
steps = approval_steps(latest_approval)
|
||||
if steps:
|
||||
if latest_approval.approved:
|
||||
confirmation_message = strategy.on_approval_accepted(steps)
|
||||
else:
|
||||
confirmation_message = strategy.on_approval_rejected(steps)
|
||||
else:
|
||||
if latest_approval.approved:
|
||||
confirmation_message = strategy.on_state_confirmed()
|
||||
else:
|
||||
confirmation_message = strategy.on_state_rejected()
|
||||
|
||||
message_id = generate_event_id()
|
||||
yield TextMessageStartEvent(message_id=message_id, role="assistant")
|
||||
yield TextMessageContentEvent(message_id=message_id, delta=confirmation_message)
|
||||
yield TextMessageEndEvent(message_id=message_id)
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
async for update in context.agent.run_stream(messages_to_run, **run_kwargs):
|
||||
update_count += 1
|
||||
logger.info(f"[STREAM] Received update #{update_count} from agent")
|
||||
all_updates.append(update)
|
||||
if all_updates is not None:
|
||||
all_updates.append(update)
|
||||
if event_bridge.current_message_id is None and update.contents:
|
||||
has_tool_call = any(isinstance(content, FunctionCallContent) for content in update.contents)
|
||||
has_text = any(isinstance(content, TextContent) for content in update.contents)
|
||||
if has_tool_call and not has_text:
|
||||
tool_message_id = generate_event_id()
|
||||
event_bridge.current_message_id = tool_message_id
|
||||
active_message_id = tool_message_id
|
||||
accumulated_text_content = ""
|
||||
logger.info(
|
||||
"[STREAM] Emitting TextMessageStartEvent for tool-only response message_id=%s",
|
||||
tool_message_id,
|
||||
)
|
||||
yield TextMessageStartEvent(message_id=tool_message_id, role="assistant")
|
||||
events = await event_bridge.from_agent_run_update(update)
|
||||
logger.info(f"[STREAM] Update #{update_count} produced {len(events)} events")
|
||||
for event in events:
|
||||
if isinstance(event, TextMessageStartEvent):
|
||||
active_message_id = event.message_id
|
||||
accumulated_text_content = ""
|
||||
elif isinstance(event, TextMessageContentEvent):
|
||||
accumulated_text_content += event.delta
|
||||
elif isinstance(event, ToolCallStartEvent):
|
||||
tool_call_entry = ensure_tool_call_entry(event.tool_call_id, tool_calls_by_id, pending_tool_calls)
|
||||
tool_call_entry["function"]["name"] = event.tool_call_name
|
||||
elif isinstance(event, ToolCallArgsEvent):
|
||||
tool_call_entry = ensure_tool_call_entry(event.tool_call_id, tool_calls_by_id, pending_tool_calls)
|
||||
tool_call_entry["function"]["arguments"] += event.delta
|
||||
elif isinstance(event, ToolCallEndEvent):
|
||||
tool_calls_ended.add(event.tool_call_id)
|
||||
elif isinstance(event, ToolCallResultEvent):
|
||||
tool_results.append(
|
||||
{
|
||||
"id": event.message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": event.tool_call_id,
|
||||
"content": event.content,
|
||||
}
|
||||
)
|
||||
logger.info(f"[STREAM] Yielding event: {type(event).__name__}")
|
||||
yield event
|
||||
if isinstance(event, ToolCallResultEvent):
|
||||
tool_name = tool_name_for_call_id(tool_calls_by_id, event.tool_call_id)
|
||||
if _should_emit_tool_snapshot(tool_name):
|
||||
messages_snapshot_emitted = True
|
||||
messages_snapshot = _build_messages_snapshot()
|
||||
logger.info(f"[STREAM] Yielding event: {type(messages_snapshot).__name__}")
|
||||
yield messages_snapshot
|
||||
elif isinstance(event, ToolCallEndEvent):
|
||||
tool_name = tool_name_for_call_id(tool_calls_by_id, event.tool_call_id)
|
||||
if tool_name == "confirm_changes":
|
||||
messages_snapshot_emitted = True
|
||||
messages_snapshot = _build_messages_snapshot()
|
||||
logger.info(f"[STREAM] Yielding event: {type(messages_snapshot).__name__}")
|
||||
yield messages_snapshot
|
||||
|
||||
logger.info(f"[STREAM] Agent stream completed. Total updates: {update_count}")
|
||||
|
||||
if event_bridge.should_stop_after_confirm:
|
||||
logger.info("Stopping run after confirm_changes - waiting for user response")
|
||||
logger.info("Stopping run - waiting for user approval/confirmation response")
|
||||
if event_bridge.current_message_id:
|
||||
logger.info(f"[CONFIRM] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
|
||||
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
|
||||
event_bridge.current_message_id = None
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
if event_bridge.pending_tool_calls:
|
||||
pending_without_end = [
|
||||
tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended
|
||||
]
|
||||
if pending_tool_calls:
|
||||
pending_without_end = [tc for tc in pending_tool_calls if tc.get("id") not in tool_calls_ended]
|
||||
if pending_without_end:
|
||||
logger.info(
|
||||
"Found %s pending tool calls without end event - emitting ToolCallEndEvent",
|
||||
@@ -470,13 +641,11 @@ class DefaultOrchestrator(Orchestrator):
|
||||
for tool_call in pending_without_end:
|
||||
tool_call_id = tool_call.get("id")
|
||||
if tool_call_id:
|
||||
from ag_ui.core import ToolCallEndEvent
|
||||
|
||||
end_event = ToolCallEndEvent(tool_call_id=tool_call_id)
|
||||
logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'")
|
||||
yield end_event
|
||||
|
||||
if all_updates and response_format:
|
||||
if response_format and all_updates:
|
||||
from agent_framework import AgentRunResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -508,37 +677,22 @@ class DefaultOrchestrator(Orchestrator):
|
||||
logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
|
||||
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
|
||||
|
||||
assistant_text_message = {
|
||||
"id": event_bridge.current_message_id,
|
||||
"role": "assistant",
|
||||
"content": event_bridge.accumulated_text_content,
|
||||
}
|
||||
|
||||
converted_input_messages = agui_messages_to_snapshot_format(event_bridge.input_messages)
|
||||
all_messages = converted_input_messages.copy()
|
||||
|
||||
if event_bridge.pending_tool_calls:
|
||||
tool_call_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": event_bridge.pending_tool_calls.copy(),
|
||||
}
|
||||
all_messages.append(tool_call_message)
|
||||
|
||||
all_messages.extend(event_bridge.tool_results.copy())
|
||||
all_messages.append(assistant_text_message)
|
||||
|
||||
messages_snapshot = MessagesSnapshotEvent(
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
)
|
||||
messages_snapshot = _build_messages_snapshot(tool_message_id=event_bridge.current_message_id)
|
||||
messages_snapshot_emitted = True
|
||||
logger.info(
|
||||
"[FINALIZE] Emitting MessagesSnapshotEvent with %s messages (text content length: %s)",
|
||||
len(all_messages),
|
||||
len(event_bridge.accumulated_text_content),
|
||||
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(messages_snapshot.messages)} messages "
|
||||
f"(text content length: {len(accumulated_text_content)})"
|
||||
)
|
||||
yield messages_snapshot
|
||||
else:
|
||||
logger.info("[FINALIZE] No current_message_id - skipping TextMessageEndEvent")
|
||||
if not messages_snapshot_emitted and (pending_tool_calls or tool_results):
|
||||
messages_snapshot = _build_messages_snapshot()
|
||||
messages_snapshot_emitted = True
|
||||
logger.info(
|
||||
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(messages_snapshot.messages)} messages"
|
||||
)
|
||||
yield messages_snapshot
|
||||
|
||||
logger.info("[FINALIZE] Emitting RUN_FINISHED event")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
|
||||
@@ -3,13 +3,29 @@
|
||||
"""Utility functions for AG-UI integration."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable, MutableMapping, Sequence
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AIFunction, ToolProtocol
|
||||
from agent_framework import AIFunction, Role, ToolProtocol
|
||||
|
||||
# Role mapping constants
|
||||
AGUI_TO_FRAMEWORK_ROLE: dict[str, Role] = {
|
||||
"user": Role.USER,
|
||||
"assistant": Role.ASSISTANT,
|
||||
"system": Role.SYSTEM,
|
||||
}
|
||||
|
||||
FRAMEWORK_TO_AGUI_ROLE: dict[Role, str] = {
|
||||
Role.USER: "user",
|
||||
Role.ASSISTANT: "assistant",
|
||||
Role.SYSTEM: "system",
|
||||
}
|
||||
|
||||
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"}
|
||||
|
||||
|
||||
def generate_event_id() -> str:
|
||||
@@ -17,6 +33,85 @@ def generate_event_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def safe_json_parse(value: Any) -> dict[str, Any] | None:
|
||||
"""Safely parse a value as JSON dict.
|
||||
|
||||
Args:
|
||||
value: String or dict to parse
|
||||
|
||||
Returns:
|
||||
Parsed dict or None if parsing fails
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_role_value(message: Any) -> str:
|
||||
"""Extract role string from a message object.
|
||||
|
||||
Handles both enum roles (with .value) and string roles.
|
||||
|
||||
Args:
|
||||
message: Message object with role attribute
|
||||
|
||||
Returns:
|
||||
Role as lowercase string, or empty string if not found
|
||||
"""
|
||||
role = getattr(message, "role", None)
|
||||
if role is None:
|
||||
return ""
|
||||
if hasattr(role, "value"):
|
||||
return str(role.value)
|
||||
return str(role)
|
||||
|
||||
|
||||
def normalize_agui_role(raw_role: Any) -> str:
|
||||
"""Normalize an AG-UI role to a standard role string.
|
||||
|
||||
Args:
|
||||
raw_role: Raw role value from AG-UI message
|
||||
|
||||
Returns:
|
||||
Normalized role string (user, assistant, system, or tool)
|
||||
"""
|
||||
if not isinstance(raw_role, str):
|
||||
return "user"
|
||||
role = raw_role.lower()
|
||||
if role == "developer":
|
||||
return "system"
|
||||
if role in ALLOWED_AGUI_ROLES:
|
||||
return role
|
||||
return "user"
|
||||
|
||||
|
||||
def extract_state_from_tool_args(
|
||||
args: dict[str, Any] | None,
|
||||
tool_arg_name: str,
|
||||
) -> Any:
|
||||
"""Extract state value from tool arguments based on config.
|
||||
|
||||
Args:
|
||||
args: Parsed tool arguments dict
|
||||
tool_arg_name: Name of the argument to extract, or "*" for entire args
|
||||
|
||||
Returns:
|
||||
Extracted state value, or None if not found
|
||||
"""
|
||||
if not args:
|
||||
return None
|
||||
if tool_arg_name == "*":
|
||||
return args
|
||||
return args.get(tool_arg_name)
|
||||
|
||||
|
||||
def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge state updates.
|
||||
|
||||
|
||||
+4
-2
@@ -75,8 +75,10 @@ def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent:
|
||||
9. "Calibrate systems"
|
||||
10. "Final testing"
|
||||
|
||||
After calling the function, provide a brief acknowledgment like:
|
||||
"I've created a plan with 10 steps. You can customize which steps to enable before I proceed."
|
||||
IMPORTANT: When you call generate_task_steps, the user will be shown the steps and asked to approve.
|
||||
Do NOT output any text along with the function call - just call the function.
|
||||
After the user approves and the function executes, THEN provide a brief acknowledgment like:
|
||||
"The plan has been created with X steps selected."
|
||||
""",
|
||||
chat_client=chat_client,
|
||||
tools=[generate_task_steps],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251223"
|
||||
version = "1.0.0b260107"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -630,3 +630,179 @@ async def test_suppressed_summary_with_document_state():
|
||||
# Should contain some reference to the document
|
||||
full_text = "".join(e.delta for e in text_events)
|
||||
assert "written" in full_text.lower() or "document" in full_text.lower()
|
||||
|
||||
|
||||
async def test_function_approval_mode_executes_tool():
|
||||
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
|
||||
from agent_framework import FunctionResultContent, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
messages_received: list[Any] = []
|
||||
|
||||
@ai_function(
|
||||
name="get_datetime",
|
||||
description="Get the current date and time",
|
||||
approval_mode="always_require",
|
||||
)
|
||||
def get_datetime() -> str:
|
||||
return "2025/12/01 12:00:00"
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
# Capture the messages received by the chat client
|
||||
messages_received.clear()
|
||||
messages_received.extend(messages)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
|
||||
|
||||
agent = ChatAgent(
|
||||
name="test_agent",
|
||||
instructions="Test",
|
||||
chat_client=StreamingChatClientStub(stream_fn),
|
||||
tools=[get_datetime],
|
||||
)
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
|
||||
# Simulate the conversation history with:
|
||||
# 1. User message asking for time
|
||||
# 2. Assistant message with the function call that needs approval
|
||||
# 3. Tool approval message from user
|
||||
tool_result: dict[str, Any] = {"accepted": True}
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What time is it?",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_get_datetime_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_datetime",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(tool_result),
|
||||
"toolCallId": "call_get_datetime_123",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Verify the run completed successfully
|
||||
run_started = [e for e in events if e.type == "RUN_STARTED"]
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_started) == 1
|
||||
assert len(run_finished) == 1
|
||||
|
||||
# Verify that a FunctionResultContent was created and sent to the agent
|
||||
# Approved tool calls are resolved before the model run.
|
||||
tool_result_found = False
|
||||
for msg in messages_received:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
tool_result_found = True
|
||||
assert content.call_id == "call_get_datetime_123"
|
||||
assert content.result == "2025/12/01 12:00:00"
|
||||
break
|
||||
|
||||
assert tool_result_found, (
|
||||
"FunctionResultContent should be included in messages sent to agent. "
|
||||
"This is required for the model to see the approved tool execution result."
|
||||
)
|
||||
|
||||
|
||||
async def test_function_approval_mode_rejection():
|
||||
"""Test that function approval rejection creates a rejection response."""
|
||||
from agent_framework import FunctionResultContent, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
messages_received: list[Any] = []
|
||||
|
||||
@ai_function(
|
||||
name="delete_all_data",
|
||||
description="Delete all user data",
|
||||
approval_mode="always_require",
|
||||
)
|
||||
def delete_all_data() -> str:
|
||||
return "All data deleted"
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
# Capture the messages received by the chat client
|
||||
messages_received.clear()
|
||||
messages_received.extend(messages)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Operation cancelled")])
|
||||
|
||||
agent = ChatAgent(
|
||||
name="test_agent",
|
||||
instructions="Test",
|
||||
chat_client=StreamingChatClientStub(stream_fn),
|
||||
tools=[delete_all_data],
|
||||
)
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
|
||||
# Simulate rejection
|
||||
tool_result: dict[str, Any] = {"accepted": False}
|
||||
input_data: dict[str, Any] = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Delete all my data",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_delete_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_all_data",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(tool_result),
|
||||
"toolCallId": "call_delete_123",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Verify the run completed
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_finished) == 1
|
||||
|
||||
# Verify that a FunctionResultContent with rejection payload was created
|
||||
rejection_found = False
|
||||
for msg in messages_received:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
rejection_found = True
|
||||
assert content.call_id == "call_delete_123"
|
||||
assert content.result == "Error: Tool call invocation was rejected by user."
|
||||
break
|
||||
|
||||
assert rejection_found, (
|
||||
"FunctionResultContent with rejection details should be included in messages sent to agent. "
|
||||
"This tells the model that the tool was rejected."
|
||||
)
|
||||
|
||||
@@ -52,8 +52,8 @@ async def test_tool_call_flow():
|
||||
update2 = AgentRunResponseUpdate(contents=[tool_result])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should have: ToolCallEndEvent, ToolCallResultEvent, MessagesSnapshotEvent
|
||||
assert len(events2) == 3
|
||||
# Should have: ToolCallEndEvent, ToolCallResultEvent
|
||||
assert len(events2) == 2
|
||||
assert isinstance(events2[0], ToolCallEndEvent)
|
||||
assert isinstance(events2[1], ToolCallResultEvent)
|
||||
|
||||
|
||||
@@ -201,7 +201,8 @@ async def test_tool_result_with_none():
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
assert events[1].content == ""
|
||||
# prepare_function_call_results serializes None as JSON "null"
|
||||
assert events[1].content == "null"
|
||||
|
||||
|
||||
async def test_multiple_tool_results_in_sequence():
|
||||
@@ -230,7 +231,12 @@ async def test_function_approval_request_basic():
|
||||
"""Test FunctionApprovalRequestContent conversion."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
# Set require_confirmation=False to test just the function_approval_request event
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
call_id="call_123",
|
||||
@@ -283,14 +289,12 @@ async def test_empty_predict_state_config():
|
||||
assert "STATE_DELTA" not in event_types
|
||||
assert "STATE_SNAPSHOT" not in event_types
|
||||
|
||||
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult, MessagesSnapshot
|
||||
# MessagesSnapshotEvent is emitted after tool results to track the conversation
|
||||
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult
|
||||
assert event_types == [
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT",
|
||||
"MESSAGES_SNAPSHOT",
|
||||
]
|
||||
|
||||
|
||||
@@ -688,3 +692,97 @@ async def test_state_delta_count_logging():
|
||||
|
||||
# State delta count should have incremented (one per unique state update)
|
||||
assert bridge.state_delta_count >= 1
|
||||
|
||||
|
||||
# Tests for list type tool results (MCP tool serialization)
|
||||
|
||||
|
||||
async def test_tool_result_with_empty_list():
|
||||
"""Test FunctionResultContent with empty list result."""
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Empty list serializes as JSON empty array
|
||||
assert events[1].content == "[]"
|
||||
|
||||
|
||||
async def test_tool_result_with_single_text_content():
|
||||
"""Test FunctionResultContent with single TextContent-like item (MCP tool result)."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentRunResponseUpdate(
|
||||
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# TextContent text is extracted and serialized as JSON array
|
||||
assert events[1].content == '["Hello from MCP tool!"]'
|
||||
|
||||
|
||||
async def test_tool_result_with_multiple_text_contents():
|
||||
"""Test FunctionResultContent with multiple TextContent-like items (MCP tool result)."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentRunResponseUpdate(
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id="call_123",
|
||||
result=[MockTextContent("First result"), MockTextContent("Second result")],
|
||||
)
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Multiple TextContent items should return JSON array
|
||||
assert events[1].content == '["First result", "Second result"]'
|
||||
|
||||
|
||||
async def test_tool_result_with_model_dump_objects():
|
||||
"""Test FunctionResultContent with Pydantic BaseModel objects."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
class MockModel(BaseModel):
|
||||
value: int
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentRunResponseUpdate(
|
||||
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Should be properly serialized JSON array without double escaping
|
||||
assert events[1].content == '[{"value": 1}, {"value": 2}]'
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent_framework import (
|
||||
from agent_framework._clients import BaseChatClient
|
||||
from agent_framework._types import ChatResponse, ChatResponseUpdate
|
||||
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
|
||||
from agent_framework_ag_ui._orchestrators import ExecutionContext
|
||||
|
||||
StreamFn = Callable[..., AsyncIterator[ChatResponseUpdate]]
|
||||
@@ -134,5 +135,9 @@ class StubAgent(AgentProtocol):
|
||||
class TestExecutionContext(ExecutionContext):
|
||||
"""ExecutionContext helper that allows setting messages for tests."""
|
||||
|
||||
def set_messages(self, messages: list[ChatMessage]) -> None:
|
||||
self._messages = messages
|
||||
def set_messages(self, messages: list[ChatMessage], *, normalize: bool = True) -> None:
|
||||
if normalize:
|
||||
self._messages = _deduplicate_messages(_sanitize_tool_history(messages))
|
||||
else:
|
||||
self._messages = messages
|
||||
self._snapshot_messages = None
|
||||
|
||||
@@ -10,9 +10,11 @@ from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
async def test_function_approval_request_emission():
|
||||
"""Test that CustomEvent is emitted for FunctionApprovalRequestContent."""
|
||||
# Set require_confirmation=False to test just the function_approval_request event
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
# Create approval request
|
||||
@@ -47,11 +49,65 @@ async def test_function_approval_request_emission():
|
||||
assert event.value["function_call"]["arguments"]["subject"] == "Test"
|
||||
|
||||
|
||||
async def test_multiple_approval_requests():
|
||||
"""Test handling multiple approval requests in one update."""
|
||||
async def test_function_approval_request_with_confirm_changes():
|
||||
"""Test that confirm_changes is also emitted when require_confirmation=True."""
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
call_id="call_456",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/test.txt"},
|
||||
)
|
||||
approval_request = FunctionApprovalRequestContent(
|
||||
id="approval_002",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentRunResponseUpdate(contents=[approval_request])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit: ToolCallEndEvent, CustomEvent, and confirm_changes (Start, Args, End) = 5 events
|
||||
assert len(events) == 5
|
||||
|
||||
# Check ToolCallEndEvent
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "call_456"
|
||||
|
||||
# Check function_approval_request CustomEvent
|
||||
assert events[1].type == "CUSTOM"
|
||||
assert events[1].name == "function_approval_request"
|
||||
|
||||
# Check confirm_changes tool call events
|
||||
assert events[2].type == "TOOL_CALL_START"
|
||||
assert events[2].tool_call_name == "confirm_changes"
|
||||
assert events[3].type == "TOOL_CALL_ARGS"
|
||||
# Verify confirm_changes includes function info for Dojo UI
|
||||
import json
|
||||
|
||||
args = json.loads(events[3].delta)
|
||||
assert args["function_name"] == "delete_file"
|
||||
assert args["function_call_id"] == "call_456"
|
||||
assert args["function_arguments"] == {"path": "/tmp/test.txt"}
|
||||
assert args["steps"] == [
|
||||
{
|
||||
"description": "Execute delete_file",
|
||||
"status": "enabled",
|
||||
}
|
||||
]
|
||||
assert events[4].type == "TOOL_CALL_END"
|
||||
|
||||
|
||||
async def test_multiple_approval_requests():
|
||||
"""Test handling multiple approval requests in one update."""
|
||||
# Set require_confirmation=False to simplify the test
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call_1 = FunctionCallContent(
|
||||
@@ -94,3 +150,32 @@ async def test_multiple_approval_requests():
|
||||
assert events[3].type == "CUSTOM"
|
||||
assert events[3].name == "function_approval_request"
|
||||
assert events[3].value["id"] == "approval_2"
|
||||
|
||||
|
||||
async def test_function_approval_request_sets_stop_flag():
|
||||
"""Test that function approval request sets should_stop_after_confirm flag.
|
||||
|
||||
This ensures the orchestrator stops the run after emitting the approval request,
|
||||
allowing the UI to send back an approval response.
|
||||
"""
|
||||
bridge = AgentFrameworkEventBridge(
|
||||
run_id="test_run",
|
||||
thread_id="test_thread",
|
||||
)
|
||||
|
||||
assert bridge.should_stop_after_confirm is False
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
call_id="call_stop_test",
|
||||
name="get_datetime",
|
||||
arguments={},
|
||||
)
|
||||
approval_request = FunctionApprovalRequestContent(
|
||||
id="approval_stop_test",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
update = AgentRunResponseUpdate(contents=[approval_request])
|
||||
await bridge.from_agent_run_update(update)
|
||||
|
||||
assert bridge.should_stop_after_confirm is True
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
"""Tests for message adapters."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
|
||||
|
||||
from agent_framework_ag_ui._message_adapters import (
|
||||
agent_framework_messages_to_agui,
|
||||
agui_messages_to_agent_framework,
|
||||
agui_messages_to_snapshot_format,
|
||||
extract_text_from_contents,
|
||||
)
|
||||
|
||||
@@ -43,6 +46,32 @@ def test_agent_framework_to_agui_basic(sample_agent_framework_message):
|
||||
assert messages[0]["id"] == "msg-123"
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_normalizes_dict_roles():
|
||||
"""Dict inputs normalize unknown roles for UI compatibility."""
|
||||
messages = [
|
||||
{"role": "developer", "content": "policy"},
|
||||
{"role": "weird_role", "content": "payload"},
|
||||
]
|
||||
|
||||
converted = agent_framework_messages_to_agui(messages)
|
||||
|
||||
assert converted[0]["role"] == "system"
|
||||
assert converted[1]["role"] == "user"
|
||||
|
||||
|
||||
def test_agui_snapshot_format_normalizes_roles():
|
||||
"""Snapshot normalization coerces roles into supported AG-UI values."""
|
||||
messages = [
|
||||
{"role": "Developer", "content": "policy"},
|
||||
{"role": "unknown", "content": "payload"},
|
||||
]
|
||||
|
||||
normalized = agui_messages_to_snapshot_format(messages)
|
||||
|
||||
assert normalized[0]["role"] == "system"
|
||||
assert normalized[1]["role"] == "user"
|
||||
|
||||
|
||||
def test_agui_tool_result_to_agent_framework():
|
||||
"""Test converting AG-UI tool result message to Agent Framework."""
|
||||
tool_result_message = {
|
||||
@@ -68,6 +97,237 @@ def test_agui_tool_result_to_agent_framework():
|
||||
assert message.additional_properties.get("tool_call_id") == "call_123"
|
||||
|
||||
|
||||
def test_agui_tool_approval_updates_tool_call_arguments():
|
||||
"""Tool approval updates matching tool call arguments for snapshots and agent context."""
|
||||
messages_input = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_task_steps",
|
||||
"arguments": {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Brew coffee", "status": "enabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"id": "msg_1",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"accepted": True,
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
],
|
||||
}
|
||||
),
|
||||
"toolCallId": "call_123",
|
||||
"id": "msg_2",
|
||||
},
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
assert len(messages) == 2
|
||||
assistant_msg = messages[0]
|
||||
func_call = next(content for content in assistant_msg.contents if isinstance(content, FunctionCallContent))
|
||||
assert func_call.arguments == {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Brew coffee", "status": "disabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
]
|
||||
}
|
||||
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Brew coffee", "status": "disabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
]
|
||||
}
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
)
|
||||
assert approval_content.function_call.parse_arguments() == {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
]
|
||||
}
|
||||
assert approval_content.additional_properties is not None
|
||||
assert approval_content.additional_properties.get("ag_ui_state_args") == {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
{"description": "Brew coffee", "status": "disabled"},
|
||||
{"description": "Serve coffee", "status": "enabled"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_agui_tool_approval_from_confirm_changes_maps_to_function_call():
|
||||
"""Confirm_changes approvals map back to the original tool call when metadata is present."""
|
||||
messages_input = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_tool",
|
||||
"type": "function",
|
||||
"function": {"name": "get_datetime", "arguments": {}},
|
||||
},
|
||||
{
|
||||
"id": "call_confirm",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "confirm_changes",
|
||||
"arguments": {"function_call_id": "call_tool"},
|
||||
},
|
||||
},
|
||||
],
|
||||
"id": "msg_1",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": True, "function_call_id": "call_tool"}),
|
||||
"toolCallId": "call_confirm",
|
||||
"id": "msg_2",
|
||||
},
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
assert approval_content.function_call.name == "get_datetime"
|
||||
assert approval_content.function_call.parse_arguments() == {}
|
||||
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {}
|
||||
|
||||
|
||||
def test_agui_tool_approval_from_confirm_changes_falls_back_to_sibling_call():
|
||||
"""Confirm_changes approvals map to the only sibling tool call when metadata is missing."""
|
||||
messages_input = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_tool",
|
||||
"type": "function",
|
||||
"function": {"name": "get_datetime", "arguments": {}},
|
||||
},
|
||||
{
|
||||
"id": "call_confirm",
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": {}},
|
||||
},
|
||||
],
|
||||
"id": "msg_1",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"accepted": True,
|
||||
"steps": [{"description": "Approve get_datetime", "status": "enabled"}],
|
||||
}
|
||||
),
|
||||
"toolCallId": "call_confirm",
|
||||
"id": "msg_2",
|
||||
},
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
assert approval_content.function_call.name == "get_datetime"
|
||||
assert approval_content.function_call.parse_arguments() == {}
|
||||
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {}
|
||||
|
||||
|
||||
def test_agui_tool_approval_from_generate_task_steps_maps_to_function_call():
|
||||
"""Approval tool payloads map to the referenced function call when function_call_id is present."""
|
||||
messages_input = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_tool",
|
||||
"type": "function",
|
||||
"function": {"name": "get_datetime", "arguments": {}},
|
||||
},
|
||||
{
|
||||
"id": "call_steps",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_task_steps",
|
||||
"arguments": {
|
||||
"function_name": "get_datetime",
|
||||
"function_call_id": "call_tool",
|
||||
"function_arguments": {},
|
||||
"steps": [{"description": "Execute get_datetime", "status": "enabled"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"id": "msg_1",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"accepted": True,
|
||||
"steps": [{"description": "Execute get_datetime", "status": "enabled"}],
|
||||
}
|
||||
),
|
||||
"toolCallId": "call_steps",
|
||||
"id": "msg_2",
|
||||
},
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
assert approval_content.function_call.name == "get_datetime"
|
||||
assert approval_content.function_call.parse_arguments() == {}
|
||||
|
||||
|
||||
def test_agui_multiple_messages_to_agent_framework():
|
||||
"""Test converting multiple AG-UI messages."""
|
||||
messages_input = [
|
||||
@@ -278,3 +538,119 @@ def test_extract_text_from_custom_contents():
|
||||
result = extract_text_from_contents(contents)
|
||||
|
||||
assert result == "Custom Mixed"
|
||||
|
||||
|
||||
# Tests for FunctionResultContent serialization in agent_framework_messages_to_agui
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_dict():
|
||||
"""Test converting FunctionResultContent with dict result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
assert agui_msg["role"] == "tool"
|
||||
assert agui_msg["toolCallId"] == "call-123"
|
||||
assert agui_msg["content"] == '{"key": "value", "count": 42}'
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_none():
|
||||
"""Test converting FunctionResultContent with None result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=None)],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
# None serializes as JSON null
|
||||
assert agui_msg["content"] == "null"
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_string():
|
||||
"""Test converting FunctionResultContent with string result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
assert agui_msg["content"] == "plain text result"
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_empty_list():
|
||||
"""Test converting FunctionResultContent with empty list result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=[])],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
# Empty list serializes as JSON empty array
|
||||
assert agui_msg["content"] == "[]"
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_single_text_content():
|
||||
"""Test converting FunctionResultContent with single TextContent-like item."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
# TextContent text is extracted and serialized as JSON array
|
||||
assert agui_msg["content"] == '["Hello from MCP!"]'
|
||||
|
||||
|
||||
def test_agent_framework_to_agui_function_result_multiple_text_contents():
|
||||
"""Test converting FunctionResultContent with multiple TextContent-like items."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class MockTextContent:
|
||||
text: str
|
||||
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id="call-123",
|
||||
result=[MockTextContent("First result"), MockTextContent("Second result")],
|
||||
)
|
||||
],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
assert len(messages) == 1
|
||||
agui_msg = messages[0]
|
||||
# Multiple items should return JSON array
|
||||
assert agui_msg["content"] == '["First result", "Second result"]'
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
|
||||
from agent_framework_ag_ui._orchestration._message_hygiene import (
|
||||
deduplicate_messages,
|
||||
sanitize_tool_history,
|
||||
)
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
|
||||
|
||||
|
||||
def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
|
||||
@@ -26,7 +23,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
|
||||
),
|
||||
]
|
||||
|
||||
sanitized = sanitize_tool_history(messages)
|
||||
sanitized = _sanitize_tool_history(messages)
|
||||
|
||||
tool_messages = [
|
||||
msg for msg in sanitized if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
@@ -48,6 +45,6 @@ def test_deduplicate_messages_prefers_non_empty_tool_results() -> None:
|
||||
),
|
||||
]
|
||||
|
||||
deduped = deduplicate_messages(messages)
|
||||
deduped = _deduplicate_messages(messages)
|
||||
assert len(deduped) == 1
|
||||
assert deduped[0].contents[0].result == "result data"
|
||||
|
||||
@@ -42,6 +42,29 @@ class DummyAgent:
|
||||
yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant")
|
||||
|
||||
|
||||
class RecordingAgent:
|
||||
"""Agent stub that captures messages passed to run_stream."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chat_options = SimpleNamespace(tools=[], response_format=None)
|
||||
self.tools: list[Any] = []
|
||||
self.chat_client = SimpleNamespace(
|
||||
function_invocation_configuration=FunctionInvocationConfiguration(),
|
||||
)
|
||||
self.seen_messages: list[Any] | None = None
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: list[Any],
|
||||
*,
|
||||
thread: Any,
|
||||
tools: list[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[AgentRunResponseUpdate, None]:
|
||||
self.seen_messages = messages
|
||||
yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant")
|
||||
|
||||
|
||||
async def test_default_orchestrator_merges_client_tools() -> None:
|
||||
"""Client tool declarations are merged with server tools before running agent."""
|
||||
|
||||
@@ -151,3 +174,104 @@ async def test_default_orchestrator_with_snake_case_ids() -> None:
|
||||
last_event = events[-1]
|
||||
assert last_event.run_id == "test-snakecase-runid"
|
||||
assert last_event.thread_id == "test-snakecase-threadid"
|
||||
|
||||
|
||||
async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
|
||||
"""State context should be injected when current state differs from tool call args."""
|
||||
|
||||
agent = RecordingAgent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
tool_recipe = {"title": "Salad", "special_preferences": []}
|
||||
current_recipe = {"title": "Salad", "special_preferences": ["Vegetarian"]}
|
||||
|
||||
input_data = {
|
||||
"state": {"recipe": current_recipe},
|
||||
"messages": [
|
||||
{"role": "system", "content": "Instructions"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "update_recipe", "arguments": {"recipe": tool_recipe}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "What are the dietary preferences?"},
|
||||
],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
state_schema={"recipe": {"type": "object"}},
|
||||
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
|
||||
require_confirmation=False,
|
||||
),
|
||||
)
|
||||
|
||||
async for _event in orchestrator.run(context):
|
||||
pass
|
||||
|
||||
assert agent.seen_messages is not None
|
||||
state_messages = []
|
||||
for msg in agent.seen_messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert state_messages
|
||||
assert "Vegetarian" in state_messages[0]
|
||||
|
||||
|
||||
async def test_state_context_not_injected_when_tool_call_matches_state() -> None:
|
||||
"""State context should be skipped when tool call args match current state."""
|
||||
|
||||
agent = RecordingAgent()
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "Instructions"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "update_recipe", "arguments": {"recipe": {}}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "What are the dietary preferences?"},
|
||||
],
|
||||
}
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
state_schema={"recipe": {"type": "object"}},
|
||||
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
|
||||
require_confirmation=False,
|
||||
),
|
||||
)
|
||||
|
||||
async for _event in orchestrator.run(context):
|
||||
pass
|
||||
|
||||
assert agent.seen_messages is not None
|
||||
state_messages = []
|
||||
for msg in agent.seen_messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert not state_messages
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_human_in_the_loop_json_decode_error() -> None:
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context.set_messages(messages)
|
||||
context.set_messages(messages, normalize=False)
|
||||
|
||||
assert orchestrator.can_handle(context)
|
||||
|
||||
@@ -385,8 +385,8 @@ async def test_state_context_injection() -> None:
|
||||
assert "banana" in system_messages[0].contents[0].text
|
||||
|
||||
|
||||
async def test_no_state_context_injection_with_tool_calls() -> None:
|
||||
"""Test state context is NOT injected if conversation has tool calls."""
|
||||
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
|
||||
"""Test state context is injected when state is provided, even with tool calls."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
|
||||
messages = [
|
||||
@@ -420,13 +420,13 @@ async def test_no_state_context_injection_with_tool_calls() -> None:
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should NOT inject state context system message since conversation has tool calls
|
||||
# Should inject state context system message because input state is provided
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
assert len(system_messages) == 0
|
||||
assert len(system_messages) == 1
|
||||
|
||||
|
||||
async def test_structured_output_processing() -> None:
|
||||
@@ -685,6 +685,54 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
|
||||
assert len(user_messages) == 1
|
||||
|
||||
|
||||
async def test_confirm_changes_closes_active_message_before_finish() -> None:
|
||||
"""Confirm-changes flow closes any active text message before run finishes."""
|
||||
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
|
||||
from agent_framework import FunctionCallContent, FunctionResultContent
|
||||
|
||||
updates = [
|
||||
AgentRunResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
name="write_document_local",
|
||||
call_id="call_1",
|
||||
arguments='{"document": "Draft"}',
|
||||
)
|
||||
]
|
||||
),
|
||||
AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Start"}]}
|
||||
agent = StubAgent(
|
||||
chat_options=DEFAULT_CHAT_OPTIONS,
|
||||
updates=updates,
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(
|
||||
predict_state_config={"document": {"tool": "write_document_local", "tool_argument": "document"}},
|
||||
require_confirmation=True,
|
||||
),
|
||||
)
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
start_events = [e for e in events if isinstance(e, TextMessageStartEvent)]
|
||||
end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
|
||||
assert len(start_events) == 1
|
||||
assert len(end_events) == 1
|
||||
assert end_events[0].message_id == start_events[0].message_id
|
||||
|
||||
end_index = events.index(end_events[0])
|
||||
finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0])
|
||||
assert end_index < finished_index
|
||||
|
||||
|
||||
async def test_tool_result_kept_when_call_id_matches() -> None:
|
||||
"""Test tool result is kept when call_id matches pending tool calls."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state
|
||||
from agent_framework_ag_ui._utils import (
|
||||
generate_event_id,
|
||||
make_json_safe,
|
||||
merge_state,
|
||||
)
|
||||
|
||||
|
||||
def test_generate_event_id():
|
||||
|
||||
Reference in New Issue
Block a user